升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。

将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 03:05:45 +08:00
parent d0555de28e
commit 1d273066b0
72 changed files with 3576 additions and 676 deletions

View File

@@ -20,11 +20,11 @@
1. 打开任意帖子详情页(如 `/post/2`
2. 在底部评论框输入:
```
```
第一行
第二行
第三行
```
```
3. 点击发送
4. 查看刚发布的评论
@@ -69,14 +69,16 @@
### 现状
| 能力 | 状态 |
|------|------|
| 数据模型 `pinned` 字段 | ✅ 已有 |
| 列表按置顶排序 | ✅ 已有 |
| 能力 | 状态 |
| ----------------------------------- | ---- |
| 数据模型 `pinned` 字段 | ✅ 已有 |
| 列表按置顶排序 | ✅ 已有 |
| API `POST /admin/api/posts/:id/pin` | ✅ 已有 |
| 旧版后台置顶按钮 | ✅ 已有 |
| React 列表/详情显示置顶徽章 | ✅ 已有 |
| **React 前台置顶操作入口** | ❌ 缺失 |
| 旧版后台置顶按钮 | ✅ 已有 |
| React 列表/详情显示置顶徽章 | ✅ 已有 |
| **React 前台置顶操作入口** | ❌ 缺失 |
### 期望方案
@@ -94,4 +96,4 @@
### 备注
适合作为 `good first issue`改动范围小、API 已就绪。
适合作为 `good first issue`改动范围小、API 已就绪。

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1 +0,0 @@
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

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

View File

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

View File

@@ -1,3 +0,0 @@
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

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

View File

@@ -0,0 +1 @@
const c="j13-feed-cache:";function r(t,e,s){return`${c}${t}:${e}:${s}`}function a(t,e,s){try{const n=sessionStorage.getItem(r(t,e,s));return n?JSON.parse(n):null}catch{return null}}function o(t,e,s,n){try{sessionStorage.setItem(r(t,e,s),JSON.stringify(n))}catch{}}function i(){try{for(let t=sessionStorage.length-1;t>=0;t--){const e=sessionStorage.key(t);e!=null&&e.startsWith(c)&&sessionStorage.removeItem(e)}}catch{}}export{i as c,a as g,o as s};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,17 +1,17 @@
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
import{p as l}from"./markdown-vendor-DxR1h-Bq.js";const m={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`约 ${s} 字的`:"一段";return`
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(t){const n=t>0?Math.min(6,Math.max(3,Math.ceil(t/42))):4,r=Array.from({length:n},(d,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=t>0?`约 ${t} 字的`:"一段";return`
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${n}
${r}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
@@ -20,4 +20,4 @@ import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-on
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};
</div>`}function _(t){return t.trim()?(new DOMParser().parseFromString(l.sanitize(t,m),"text/html").body.textContent??"").trim().length===0:!0}function y(t,n){if(!t.trim())return"";const r=new DOMParser().parseFromString(l.sanitize(t,m),"text/html");return r.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!n){const s=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(s);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(s=>!(s instanceof Element&&s.classList.contains("post-members-only__badge"))).map(s=>s instanceof Element?s.outerHTML:s.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),r.body.innerHTML}export{m as P,_ as i,y as r};

View File

@@ -27,10 +27,10 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-DurEdRMk.js"></script>
<script type="module" crossorigin src="/assets/index-L-Mx2Hle.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">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DGgNN2YO.js">
<link rel="stylesheet" crossorigin href="/assets/index-7vBz8mtL.css">
</head>
<body>
<div id="root"></div>

View File

@@ -16,10 +16,19 @@
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
@@ -1921,6 +1930,461 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
"integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz",
"integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz",
"integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz",
"integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz",
"integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz",
"integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz",
"integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz",
"integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz",
"integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz",
"integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz",
"integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz",
"integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz",
"integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz",
"integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.26.1.tgz",
"integrity": "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz",
"integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz",
"integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz",
"integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz",
"integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz",
"integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz",
"integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz",
"integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.26.1.tgz",
"integrity": "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz",
"integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
"integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz",
"integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz",
"integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz",
"integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.7",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.0",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.1.tgz",
"integrity": "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.26.1",
"@tiptap/extension-floating-menu": "^3.26.1"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz",
"integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.26.1",
"@tiptap/extension-blockquote": "^3.26.1",
"@tiptap/extension-bold": "^3.26.1",
"@tiptap/extension-bullet-list": "^3.26.1",
"@tiptap/extension-code": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1",
"@tiptap/extension-document": "^3.26.1",
"@tiptap/extension-dropcursor": "^3.26.1",
"@tiptap/extension-gapcursor": "^3.26.1",
"@tiptap/extension-hard-break": "^3.26.1",
"@tiptap/extension-heading": "^3.26.1",
"@tiptap/extension-horizontal-rule": "^3.26.1",
"@tiptap/extension-italic": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-list": "^3.26.1",
"@tiptap/extension-list-item": "^3.26.1",
"@tiptap/extension-list-keymap": "^3.26.1",
"@tiptap/extension-ordered-list": "^3.26.1",
"@tiptap/extension-paragraph": "^3.26.1",
"@tiptap/extension-strike": "^3.26.1",
"@tiptap/extension-text": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/extensions": "^3.26.1",
"@tiptap/pm": "^3.26.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1987,14 +2451,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2005,7 +2467,6 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2018,6 +2479,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2299,7 +2766,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
@@ -2338,6 +2804,15 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2416,6 +2891,15 @@
"node": ">=6"
}
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2660,6 +3144,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2797,6 +3287,12 @@
"node": ">= 6"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2995,6 +3491,145 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.9",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.9",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3264,6 +3899,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3599,6 +4240,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3665,6 +4315,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -17,10 +17,19 @@
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",

View File

@@ -1,4 +1,4 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings } from './types';
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, PostDetailResponse, PostRevision } from './types';
const BASE = '';
@@ -30,7 +30,7 @@ export const api = {
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
},
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`),
post: (id: number) => request<PostDetailResponse>(`/api/posts/${id}`),
comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
@@ -60,6 +60,18 @@ export const api = {
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminLockPost: (id: number, locked: boolean) =>
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminUpdateForumSettings: (body: { post_edit_window_hours: number }) =>
request<{ message: string; post_edit_window_hours: number }>('/api/admin/settings/forum', {
method: 'PUT', body: JSON.stringify(body),
}),
postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminComments: (page = 1) =>
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(

View File

@@ -30,14 +30,39 @@ export interface PostItem {
content?: string;
tags: string;
pinned: boolean;
edit_locked?: boolean;
like_count: number;
view_count: number;
comment_count: number;
last_reply_at?: string;
created_at: string;
updated_at?: string;
board?: Board;
user?: User;
}
export interface PostRevision {
id: number;
post_id: number;
editor_id: number;
title: string;
content: string;
tags: string;
created_at: string;
editor?: User;
}
export interface PostDetailResponse {
post: PostItem;
comment_count: number;
liked: boolean;
favorited: boolean;
can_edit?: boolean;
edit_block_reason?: string;
is_edited?: boolean;
post_edit_window_hours?: number;
}
export interface Comment {
id: number;
post_id: number;
@@ -70,6 +95,7 @@ export interface AdminSettings {
data_dir: string;
db_path: string;
port: number;
post_edit_window_hours: number;
}
export interface Paginated<T> {

View File

@@ -1,16 +1,23 @@
import {
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Strikethrough, Link, Code, Quote,
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
} from 'lucide-react';
import { markdownToHtml, countWords } from '../utils/markdown';
import { renderPostContentHtml } from '../utils/postContent';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { countWords } from '../utils/markdown';
import { MembersOnly } from './editor/MembersOnlyExtension';
export interface ArticleEditorHandle {
getHTML: () => string;
getMarkdown: () => string;
isEmpty: () => boolean;
focus: () => void;
}
@@ -21,181 +28,206 @@ interface Props {
placeholder?: string;
}
type ViewMode = 'edit' | 'preview' | 'split';
interface ToolBtn {
icon: ReactNode;
title: string;
active?: boolean;
className?: string;
action: () => void;
}
/** 去掉行首已有的 Markdown 块级前缀 */
function stripLinePrefix(line: string): string {
return line
.replace(/^\r/, '')
.replace(/^#{1,6}\s*/, '')
.replace(/^>\s*/, '')
.replace(/^[-*+]\s*/, '')
.replace(/^\d+\.\s*/, '');
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 判断编辑器内容是否为空 */
function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
if (editor.isActive('heading', { level })) {
if (level === 6) {
editor.chain().focus().setParagraph().run();
} else {
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
}
return;
}
}
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectionRef = useRef({ start: 0, end: 0 });
const [viewMode, setViewMode] = useState<ViewMode>('split');
const [previewHtml, setPreviewHtml] = useState('');
const isInternalUpdate = useRef(false);
const lastValueRef = useRef(value);
const [, setEditorTick] = useState(0);
useImperativeHandle(ref, () => ({
getHTML: () => markdownToHtml(value),
getMarkdown: () => value,
isEmpty: () => value.trim().length === 0,
focus: () => textareaRef.current?.focus(),
}));
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({ placeholder }),
MembersOnly,
],
content: sanitizeHtml(value) || '',
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
},
onSelectionUpdate: () => setEditorTick(t => t + 1),
onTransaction: () => setEditorTick(t => t + 1),
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
},
});
const saveSelection = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
}, []);
const getSelection = useCallback(() => {
const ta = textareaRef.current;
if (ta && document.activeElement === ta) {
return { start: ta.selectionStart, end: ta.selectionEnd };
}
return selectionRef.current;
}, []);
const restoreSelection = useCallback((start: number, end = start) => {
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(start, end);
selectionRef.current = { start, end };
});
}, []);
// 实时预览,短 debounce 保证流畅
// 外部 value 变更时同步到编辑器(如加载已有帖子)
useEffect(() => {
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
return () => clearTimeout(t);
}, [value]);
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
const adjustTextareaHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta || viewMode === 'preview') return;
ta.style.height = '0px';
const contentHeight = ta.scrollHeight;
const top = ta.getBoundingClientRect().top;
const minHeight = Math.max(280, window.innerHeight - top - 56);
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
}, [viewMode]);
useEffect(() => {
adjustTextareaHeight();
}, [value, viewMode, adjustTextareaHeight]);
useEffect(() => {
const onResize = () => adjustTextareaHeight();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [adjustTextareaHeight]);
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
const ta = textareaRef.current;
if (!ta) return;
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
restoreSelection(start + before.length + selected.length);
}, [value, onChange, getSelection, restoreSelection]);
const wrapLine = useCallback((prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const stripped = stripLinePrefix(line);
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
onChange(next);
restoreSelection(lineStart + prefix.length + stripped.length);
}, [value, onChange, getSelection, restoreSelection]);
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
const toggleHeading = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const normalized = line.replace(/^\r/, '');
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
let nextLine: string;
if (match) {
const level = match[1].length;
const text = match[2];
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
} else {
nextLine = `## ${stripLinePrefix(normalized)}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
onChange(next);
const cursor = lineStart + nextLine.length;
restoreSelection(cursor);
}, [value, onChange, getSelection, restoreSelection]);
/** 包裹为仅登录用户可见区块 */
const wrapMembersOnly = useCallback(() => {
const { start, end } = getSelection();
if (start !== end) {
const selected = value.slice(start, end);
const block = `\n:::members\n${selected}\n:::\n`;
const next = value.slice(0, start) + block + value.slice(end);
onChange(next);
restoreSelection(start + ':::members\n'.length + 1);
if (!editor) return;
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
const next = sanitizeHtml(value);
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor]);
const tools: ToolBtn[] = [
{ icon: <strong>H</strong>, title: '标题H2再次点击升级', action: toggleHeading },
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('![', '](url)', '描述') },
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
];
useImperativeHandle(ref, () => ({
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
focus: () => editor?.commands.focus(),
}), [editor, value]);
const displayPreviewHtml = useMemo(() => {
if (!value.trim()) {
return `<p class="article-preview-placeholder">${placeholder}</p>`;
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
if (!url.trim()) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
return renderPostContentHtml(previewHtml, true);
}, [value, previewHtml, placeholder]);
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
}, [editor]);
const words = countWords(value);
const showEdit = viewMode === 'edit' || viewMode === 'split';
const showPreview = viewMode === 'preview' || viewMode === 'split';
const setImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('图片地址');
if (!url?.trim()) return;
editor.chain().focus().setImage({ src: url.trim() }).run();
}, [editor]);
const wrapMembersOnly = useCallback(() => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapMembersOnly().run();
return;
}
editor.chain().focus().insertMembersOnly().run();
}, [editor]);
const buildTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
{
icon: <strong>H</strong>,
title: '标题H2再次点击升级',
active: editor.isActive('heading'),
action: () => cycleHeading(editor),
},
{
icon: <Bold size={15} />,
title: '加粗',
active: editor.isActive('bold'),
action: () => editor.chain().focus().toggleBold().run(),
},
{
icon: <Italic size={15} />,
title: '斜体',
active: editor.isActive('italic'),
action: () => editor.chain().focus().toggleItalic().run(),
},
{
icon: <Strikethrough size={15} />,
title: '删除线',
active: editor.isActive('strike'),
action: () => editor.chain().focus().toggleStrike().run(),
},
{
icon: <Minus size={15} />,
title: '分割线',
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: <Quote size={15} />,
title: '引用',
active: editor.isActive('blockquote'),
action: () => editor.chain().focus().toggleBlockquote().run(),
},
{
icon: <List size={15} />,
title: '无序列表',
active: editor.isActive('bulletList'),
action: () => editor.chain().focus().toggleBulletList().run(),
},
{
icon: <ListOrdered size={15} />,
title: '有序列表',
active: editor.isActive('orderedList'),
action: () => editor.chain().focus().toggleOrderedList().run(),
},
{
icon: <Code size={15} />,
title: '代码块',
active: editor.isActive('codeBlock'),
action: () => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <LinkIcon size={15} />,
title: '链接',
active: editor.isActive('link'),
action: setLink,
},
{
icon: <ImageIcon size={15} />,
title: '图片',
action: setImage,
},
{
icon: <LockKeyhole size={15} />,
title: '登录可见(选中文字后点击可包裹)',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
},
];
}, [editor, setLink, setImage, wrapMembersOnly]);
const tools = buildTools();
const words = editor ? countWords(editor.getText()) : 0;
return (
<div className="article-editor">
@@ -205,7 +237,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
<button
key={i}
type="button"
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
@@ -214,65 +246,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
</button>
))}
</div>
<div className="article-editor-modes">
<button
type="button"
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
onClick={() => setViewMode('edit')}
title="仅编辑"
>
<Pencil size={14} />
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
onClick={() => setViewMode('split')}
title="分栏预览"
>
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
onClick={() => setViewMode('preview')}
title="仅预览"
>
<Eye size={14} />
</button>
</div>
</div>
<div className={`article-editor-panes article-editor-panes--${viewMode}`}>
{showEdit && (
<div className="article-pane article-pane--edit">
<textarea
ref={textareaRef}
className="article-textarea"
value={value}
onChange={e => onChange(e.target.value)}
onSelect={saveSelection}
onKeyUp={saveSelection}
onClick={saveSelection}
onFocus={saveSelection}
placeholder={placeholder}
spellCheck={false}
/>
</div>
)}
{showPreview && (
<div className="article-pane article-pane--preview">
{viewMode === 'split' && <div className="article-pane-label"></div>}
<div
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
/>
</div>
)}
<div className="article-editor-body">
<EditorContent editor={editor} className="article-editor-content" />
</div>
<div className="article-editor-status">
<span>{words} </span>
<span>Markdown</span>
<span></span>
</div>
</div>
);

View File

@@ -1,70 +0,0 @@
import { Button } from '@/components/ui/button';
import { LayoutGrid, Folder, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boards: Board[];
loading?: boolean;
selectedId?: number;
onSelect: (id: number) => void;
}
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
const nav = useNavigate();
const { user } = useAuth();
if (loading) {
return (
<div className="board-grid board-grid--skeleton" aria-hidden>
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="board-tab board-tab--skeleton" />
))}
</div>
);
}
if (boards.length === 0) {
return (
<div className="board-grid-empty">
<p className="text-sm text-muted-foreground py-4 text-center">
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/admin/boards')}>
<Plus />
</Button>
</div>
)}
</div>
);
}
return (
<div className="board-grid">
<button
type="button"
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
onClick={() => onSelect(0)}
>
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
<span className="board-tab-name"></span>
</button>
{boards.map(b => (
<button
key={b.id}
type="button"
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
title={b.description ? `${b.name}${b.description}` : b.name}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-icon"><Folder size={16} /></span>
<span className="board-tab-name">{b.name}</span>
</button>
))}
</div>
);
}

View File

@@ -1,8 +1,5 @@
import { Button } from '@/components/ui/button';
import { Plus, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boardId: number;
@@ -14,55 +11,40 @@ interface Props {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const board = boards.find(b => b.id === boardId);
const title = keyword
? `搜索:${keyword}`
: (boardId && board ? board.name : '全部帖子');
const hint = keyword
? `找到 ${postTotal} 篇相关帖子`
: boardId && board
? (board.description || '欢迎在本板块交流讨论')
: '姜十三论坛 · 拾三一隅,自在交流';
const boardHint = boardId && board ? (board.description || '') : '';
return (
<div className="feed-banner">
<div className="feed-banner-row">
<div className="feed-banner-title">
<h2>{title}</h2>
<p>{hint}</p>
</div>
{!keyword && (
<div className="feed-actions flex flex-wrap items-center gap-2">
{authLoading ? (
<span className="feed-action-slot" aria-hidden />
) : user ? (
<Button
size="sm"
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
>
<Plus />
{boardId ? '在此发帖' : '发布帖子'}
</Button>
) : (
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>
)}
</div>
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<span className="feed-head__meta">
<strong>{stats.users}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.posts}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.boards}</strong>
</span>
)}
</div>
<div className="feed-stats">
<span> <strong>{stats?.users ?? '—'}</strong></span>
<span> <strong>{stats?.posts ?? '—'}</strong></span>
<span> <strong>{stats?.boards ?? '—'}</strong></span>
</div>
{keyword && (
<button
type="button"
className="feed-head__clear"
onClick={() => nav('/')}
>
</button>
)}
{keyword && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
}
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'reply' || raw === 'hot') return raw;
return 'latest';
}
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
return (
<div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
</div>
{postTotal != null && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Pin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 置顶图钉标识 */
export default function PinnedIcon({ className, size = 16 }: Props) {
return (
<Pin
className={cn('post-pinned-icon', className)}
size={size}
fill="currentColor"
aria-label="置顶"
role="img"
/>
);
}

View File

@@ -1,14 +1,22 @@
import { Badge } from '@/components/ui/badge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
interface Props {
post: PostItem;
sort?: FeedSort;
onClick: () => void;
}
export default function PostListItem({ post, onClick }: Props) {
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
return (
<div className="post-row" onClick={onClick}>
@@ -17,13 +25,13 @@ export default function PostListItem({ post, onClick }: Props) {
</div>
<div className="post-body">
<div className="post-title">
{post.pinned && <Badge variant="orange" className="mr-1.5"></Badge>}
{post.pinned && <PinnedIcon className="mr-1.5" />}
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
<span>{post.user?.nickname || '匿名'}</span>
<span>{formatTime(post.created_at)}</span>
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">

View File

@@ -0,0 +1,334 @@
import { useEffect, useMemo, useState } from 'react';
import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import {
type PostSnapshot,
htmlToDiffText,
summarizeChange,
diffTextLines,
diffTextWords,
countLineChanges,
} from '../utils/revisionDiff';
interface Props {
postId: number;
currentPost: PostSnapshot;
open: boolean;
onClose: () => void;
isLoggedIn: boolean;
}
type ViewMode = 'diff' | 'before' | 'after';
interface RevisionEntry {
rev: PostRevision;
after: PostSnapshot;
summary: ReturnType<typeof summarizeChange>;
index: number;
}
function DiffWords({ before, after }: { before: string; after: string }) {
const parts = diffTextWords(before, after);
if (before === after) {
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
}
return (
<span className="revision-diff-inline">
{parts.map((part, i) => {
if (part.added) return <ins key={i}>{part.value}</ins>;
if (part.removed) return <del key={i}>{part.value}</del>;
return <span key={i}>{part.value}</span>;
})}
</span>
);
}
function DiffLines({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none"></span>;
return (
<>
{summary.titleChanged && <span className="revision-badge"></span>}
{summary.contentChanged && <span className="revision-badge"></span>}
{summary.tagsChanged && <span className="revision-badge"></span>}
</>
);
}
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
const [revisions, setRevisions] = useState<PostRevision[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!open) {
setSelectedId(null);
setViewMode('diff');
setFullscreen(false);
return;
}
setLoading(true);
api.postRevisions(postId)
.then(d => {
const list = d.revisions ?? [];
setRevisions(list);
if (list.length > 0) setSelectedId(list[0].id);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
.finally(() => setLoading(false));
}, [open, postId]);
// 阻止背景滚动
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const entries: RevisionEntry[] = useMemo(() => {
return revisions.map((rev, index) => {
const after: PostSnapshot = index === 0
? currentPost
: {
title: revisions[index - 1].title,
content: revisions[index - 1].content,
tags: revisions[index - 1].tags,
};
const before: PostSnapshot = {
title: rev.title,
content: rev.content,
tags: rev.tags,
};
return {
rev,
after,
summary: summarizeChange(before, after),
index: revisions.length - index,
};
});
}, [revisions, currentPost]);
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
if (!open) return null;
const beforeSnap: PostSnapshot | null = selected
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
: null;
return (
<div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose}
>
<div
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="编辑历史"
>
<header className="post-revision-head">
<div className="post-revision-head-left">
<History size={18} />
<h3></h3>
{selected && (
<span className="post-revision-head-sub">
{selected.index}
</span>
)}
</div>
<div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist">
{([
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)}
>
<Icon size={14} />
<span className="post-revision-tab-label">{label}</span>
</button>
))}
</div>
<button
type="button"
className="post-revision-icon-btn"
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
>
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
<X size={18} />
</button>
</div>
</header>
{loading ? (
<div className="post-revision-loading"><Spinner size="lg" /></div>
) : entries.length === 0 ? (
<p className="post-revision-empty"></p>
) : (
<div className="post-revision-body">
<aside className="post-revision-sidebar">
<div className="post-revision-sidebar-label">线</div>
<ul className="post-revision-list">
{entries.map(entry => (
<li key={entry.rev.id}>
<button
type="button"
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
onClick={() => setSelectedId(entry.rev.id)}
>
<div className="post-revision-item-head">
<span className="post-revision-item-num">#{entry.index}</span>
<ChangeBadges summary={entry.summary} />
</div>
<span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta">
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
</span>
</button>
</li>
))}
</ul>
<div className="post-revision-current">
<span className="post-revision-current-label"></span>
<span className="post-revision-current-title">{currentPost.title}</span>
</div>
</aside>
<main className="post-revision-main">
{selected && beforeSnap ? (
<>
<div className="post-revision-main-head">
<div>
<span className="post-revision-main-editor">
{selected.rev.editor?.nickname ?? '未知'}
</span>
<span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)}
</span>
</div>
<ChangeBadges summary={selected.summary} />
</div>
<div className="post-revision-scroll">
{viewMode === 'diff' && (
<div className="revision-diff-view">
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords before={beforeSnap.title} after={selected.after.title} />
</div>
</section>
{(beforeSnap.tags || selected.after.tags) && (
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords
before={beforeSnap.tags || '(无)'}
after={selected.after.tags || '(无)'}
/>
</div>
</section>
)}
<section className="revision-diff-section revision-diff-section--content">
<h4></h4>
<DiffLines
before={htmlToDiffText(beforeSnap.content)}
after={htmlToDiffText(selected.after.content)}
/>
</section>
</div>
)}
{viewMode === 'before' && (
<div className="revision-full-view">
<h4>{beforeSnap.title}</h4>
{beforeSnap.tags && (
<p className="revision-full-tags">{beforeSnap.tags}</p>
)}
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
</div>
)}
{viewMode === 'after' && (
<div className="revision-full-view">
<h4>{selected.after.title}</h4>
{selected.after.tags && (
<p className="revision-full-tags">{selected.after.tags}</p>
)}
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
</div>
)}
</div>
</>
) : (
<div className="post-revision-empty"></div>
)}
</main>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import {
Home, Star, LayoutDashboard,
} from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -29,6 +30,8 @@ interface Props {
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
@@ -50,7 +53,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav>
@@ -63,7 +66,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>

View File

@@ -1,19 +1,36 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import PostListItem from './PostListItem';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
}
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
export default function VirtualPostList({
posts,
sort = 'latest',
loading,
hasMore,
onLoadMore,
onSelect,
restoreScrollTop,
onScrollTopChange,
onScrollRestored,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const virtualizer = useVirtualizer({
count: posts.length,
@@ -22,17 +39,29 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
overscan: 8,
});
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
onLoadMore();
}
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, onLoadMore]);
}, [hasMore, loading, onLoadMore, onScrollTopChange]);
return (
<div className="post-list-scroll" ref={parentRef}>
@@ -50,7 +79,7 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} onClick={() => onSelect(post.id)} />
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
</div>
);
})}

View File

@@ -0,0 +1,84 @@
import { Node, mergeAttributes } from '@tiptap/core';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole } from 'lucide-react';
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected }: NodeViewProps) {
return (
<NodeViewWrapper
as="members-only"
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
>
<div className="post-members-only__badge" contentEditable={false}>
<span className="post-members-only__badge-icon" aria-hidden="true">
<LockKeyhole size={12} />
</span>
<span></span>
</div>
<NodeViewContent className="post-members-only__body" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
membersOnly: {
insertMembersOnly: () => ReturnType;
wrapMembersOnly: () => ReturnType;
};
}
}
/** TipTap 自定义节点:登录可见内容区块 */
export const MembersOnly = Node.create({
name: 'membersOnly',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'members-only' }];
},
renderHTML({ HTMLAttributes }) {
return ['members-only', mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(MembersOnlyView);
},
addCommands() {
return {
insertMembersOnly: () => ({ chain }) => chain()
.insertContent({
type: this.name,
content: [{
type: 'paragraph',
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
}],
})
.run(),
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { from, to, empty } = state.selection;
if (empty) return false;
const slice = state.doc.slice(from, to);
if (!slice.content.size) return false;
const node = state.schema.nodes.membersOnly.create(null, slice.content);
if (dispatch) {
tr.replaceRangeWith(from, to, node);
}
return true;
},
};
},
});

View File

@@ -35,6 +35,9 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
if (!root) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
if (e.ctrlKey || e.metaKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return;

View File

@@ -16,6 +16,7 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -34,6 +35,7 @@ export default function MainLayout() {
const [online, setOnline] = useState<OnlineStats | null>(null);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
@@ -195,13 +197,13 @@ export default function MainLayout() {
<div className="mobile-board-bar">
<span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav('/'); }}
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
))}
</div>

View File

@@ -5,9 +5,9 @@ import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
export default function ComposePage() {
const nav = useNavigate();
@@ -37,16 +37,21 @@ export default function ComposePage() {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) {
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
if (!isOwnerOrAdmin) {
notify.error('无权编辑此帖子');
nav(`/post/${editId}`);
return;
}
if (!postData.can_edit) {
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
setTitle(post.title);
setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? ''));
setContent(post.content ?? '');
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -108,13 +113,13 @@ export default function ComposePage() {
const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: markdownToHtml(content),
content: content.trim(),
tags: tags.trim(),
};
if (isEdit) {
@@ -204,7 +209,7 @@ export default function ComposePage() {
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -6,7 +6,8 @@ import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
export default function HomePage() {
const nav = useNavigate();
@@ -14,17 +15,27 @@ export default function HomePage() {
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const sort = parseFeedSort(params.get('sort'));
const initialCache = getFeedCache(boardId, keyword, sort);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
const [page, setPage] = useState(() => initialCache?.page ?? 1);
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
const [loading, setLoading] = useState(() => !initialCache);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
const data = await api.posts({
page: p,
size: 30,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
@@ -37,50 +48,113 @@ export default function HomePage() {
} finally {
setLoading(false);
}
}, [boardId, keyword]);
}, [boardId, keyword, sort]);
/** 有缓存时静默拉取已加载页,更新置顶等状态同时保留滚动位置 */
const revalidate = useCallback(async (maxPage: number) => {
try {
const all: PostItem[] = [];
let total = 0;
let hasMore = true;
for (let p = 1; p <= maxPage; p++) {
const data = await api.posts({
page: p,
size: 30,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
all.push(...batch);
total = data.total ?? 0;
hasMore = !!data.has_more;
if (!data.has_more) break;
}
setPosts(all);
setPostTotal(total);
setHasMore(hasMore);
setPage(maxPage);
} catch {
// 静默失败,保留缓存数据
}
}, [boardId, keyword, sort]);
useEffect(() => {
const cached = getFeedCache(boardId, keyword, sort);
if (cached) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
setHasMore(cached.hasMore);
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
revalidate(cached.page);
return;
}
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
}, [boardId, keyword, load]);
}, [boardId, keyword, sort, load, revalidate]);
useEffect(() => {
const fn = () => load(1, true);
return () => {
if (posts.length === 0) return;
setFeedCache(boardId, keyword, sort, {
posts,
postTotal,
page,
hasMore,
scrollTop: scrollTopRef.current,
});
};
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
useEffect(() => {
const fn = () => {
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [load]);
}, [boardId, keyword, sort, load]);
const showBoardGrid = !keyword;
const handleSortChange = (next: FeedSort) => {
if (next === sort) return;
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
nav(buildHomeUrl(boardId, next));
};
const showSortBar = !keyword;
return (
<div className="page-wrap">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showBoardGrid && (
<BoardGrid
<div className="feed-top">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
loading={!ctx?.layoutReady}
selectedId={boardId}
onSelect={(id) => {
ctx?.setBoardId(id);
nav(id ? `/?board=${id}` : '/');
}}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
)}
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading}
hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
restoreScrollTop={restoreScrollTop}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -10,9 +11,11 @@ import type { PostItem, Comment } from '../api/types';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import PostRevisionPanel from '../components/PostRevisionPanel';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { clearAllFeedCache } from '../utils/feedCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
export default function PostDetailPage() {
@@ -30,6 +33,10 @@ export default function PostDetailPage() {
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const [canEdit, setCanEdit] = useState(false);
const [isEdited, setIsEdited] = useState(false);
const [editBlockReason, setEditBlockReason] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
@@ -55,6 +62,9 @@ export default function PostDetailPage() {
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(commList);
await refresh();
} catch (e: unknown) {
@@ -161,14 +171,34 @@ 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 isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
const isAdmin = user?.role === 'admin';
const showEdited = isEdited && post.updated_at;
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleLock = async () => {
if (!post) return;
try {
const r = await api.adminLockPost(postId, !post.edit_locked);
setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p);
if (user?.role === 'admin') {
setCanEdit(true);
} else if (user?.id === post.user_id && r.edit_locked) {
setCanEdit(false);
setEditBlockReason('帖子已被管理员锁定,无法编辑');
}
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
@@ -190,7 +220,7 @@ export default function PostDetailPage() {
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
{post.title}
</h1>
<div className="post-detail-author-row">
@@ -200,7 +230,16 @@ export default function PostDetailPage() {
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<span className="post-detail-meta-line">
{formatTime(post.created_at)} · {post.view_count}
{formatDateTime(post.created_at)}
{showEdited && (
<> · {formatDateTime(post.updated_at!)}</>
)}
{' · '}{post.view_count}
{post.edit_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
<Lock size={12} />
</span>
)}
</span>
</div>
</div>
@@ -229,15 +268,40 @@ export default function PostDetailPage() {
</Button>
)}
{isAdmin && (
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
{isOwnerOrAdmin && isEdited && (
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
<History />
</Button>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
</span>
)}
{isAdmin && (
<>
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
</>
)}
</div>
</div>
<PostRevisionPanel
postId={postId}
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
open={showRevisions}
onClose={() => setShowRevisions(false)}
isLoggedIn={!!user}
/>
<div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar">
<span className="comment-section-title"></span>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Search, Lock, LockOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -14,6 +14,7 @@ import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
import { clearAllFeedCache } from '../../utils/feedCache';
export default function AdminPostsPage() {
const nav = useNavigate();
@@ -44,6 +45,18 @@ export default function AdminPostsPage() {
const togglePin = async (post: PostItem) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const toggleLock = async (post: PostItem) => {
try {
const r = await api.adminLockPost(post.id, !post.edit_locked);
notify.success(r.message);
load();
} catch (e: unknown) {
@@ -100,6 +113,7 @@ export default function AdminPostsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -118,6 +132,7 @@ export default function AdminPostsPage() {
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
@@ -126,6 +141,9 @@ export default function AdminPostsPage() {
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> </> : <><Lock size={14} /> </>}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
@@ -12,14 +13,37 @@ export default function AdminSettingsPage() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [editWindowHours, setEditWindowHours] = useState('24');
const [savingForum, setSavingForum] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(setSettings)
.then(s => {
setSettings(s);
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
})
.finally(() => setLoading(false));
}, [ready]);
const handleSaveForumSettings = async () => {
const hours = parseInt(editWindowHours, 10);
if (Number.isNaN(hours) || hours < 0) {
notify.warning('请输入有效的小时数0 表示不限)');
return;
}
setSavingForum(true);
try {
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
notify.success(r.message);
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingForum(false);
}
};
const handleBackup = async () => {
setBacking(true);
try {
@@ -45,6 +69,27 @@ export default function AdminSettingsPage() {
<p></p>
</div>
<div className="admin-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
0
</p>
<div className="admin-form-row">
<label htmlFor="edit-window-hours"></label>
<Input
id="edit-window-hours"
type="number"
min={0}
value={editWindowHours}
onChange={e => setEditWindowHours(e.target.value)}
className="max-w-[120px]"
/>
<Button onClick={handleSaveForumSettings} loading={savingForum}>
</Button>
</div>
</div>
<div className="admin-card">
<div className="admin-card-head"></div>
<dl className="admin-dl">

View File

@@ -513,7 +513,7 @@ a:hover { text-decoration: underline; }
.header-inner { padding: 0 12px; gap: 10px; }
.header-search-wrap { max-width: none; }
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
.feed-banner-row { flex-direction: column; gap: 10px; }
.feed-banner-row { flex-direction: row; gap: 10px; }
.board-grid { flex-wrap: nowrap; overflow-x: auto; padding: 8px 12px; scrollbar-width: none; }
.board-grid::-webkit-scrollbar { display: none; }
}
@@ -533,7 +533,8 @@ a:hover { text-decoration: underline; }
display: none;
}
.virtual-list-wrap {
.virtual-list-wrap,
.post-list-scroll {
flex: 1;
overflow: auto;
min-height: 0;
@@ -541,7 +542,8 @@ a:hover { text-decoration: underline; }
-ms-overflow-style: none;
}
.virtual-list-wrap::-webkit-scrollbar {
.virtual-list-wrap::-webkit-scrollbar,
.post-list-scroll::-webkit-scrollbar {
display: none;
}
.page-inner { padding: 20px 24px; max-width: 720px; }
@@ -574,31 +576,131 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border-light);
}
.feed-top {
flex-shrink: 0;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block);
}
@media (min-width: 900px) {
.feed-top:not(:has(.feed-head--solo)) {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 16px;
}
.feed-top:not(:has(.feed-head--solo)) .feed-head {
padding: 0;
flex-shrink: 0;
}
.feed-top:not(:has(.feed-head--solo)) .feed-toolbar {
padding: 0;
flex: 1;
min-width: 0;
}
}
.feed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px 6px;
}
.feed-head--solo {
padding-bottom: 8px;
}
.feed-head__title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
flex: 1;
}
.feed-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.feed-head__meta {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-head__meta strong {
color: var(--j13-green);
font-weight: 600;
}
.feed-head__dot {
margin: 0 4px;
opacity: 0.55;
}
.feed-head__clear {
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
font-size: 12px;
color: var(--j13-green);
cursor: pointer;
font-family: inherit;
}
.feed-head__clear:hover {
text-decoration: underline;
}
.feed-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 8px;
}
.feed-toolbar__count {
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
}
/* 兼容旧类名,避免其他页面引用失效 */
.feed-banner {
padding: 14px 20px 12px;
padding: 8px 16px;
border-bottom: 1px solid var(--j13-border-light);
}
.feed-banner-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 12px;
}
.feed-banner-title { flex: 1; min-width: 0; }
.feed-banner h2 { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
.feed-banner h2 { margin: 0; font-size: 15px; font-weight: 600; }
.feed-banner p { margin: 0; font-size: 12px; color: var(--color-text-3); line-height: 1.45; }
.feed-stats {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
margin-top: 10px;
min-height: 20px;
font-size: 13px;
color: var(--color-text-2);
gap: 8px 12px;
margin-top: 6px;
font-size: 12px;
color: var(--color-text-3);
}
.feed-stats strong { color: var(--j13-green); font-weight: 600; }
@@ -637,9 +739,10 @@ a:hover { text-decoration: underline; }
}
.board-chip.active {
background: var(--j13-green);
border-color: var(--j13-green);
color: #fff;
background: var(--j13-green-bg);
border-color: transparent;
color: var(--j13-green);
font-weight: 600;
}
.board-grid {
@@ -650,6 +753,67 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border-light);
}
/* 帖子排序栏:与左侧栏 active 样式保持一致 */
.feed-sort-bar {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.feed-sort-bar::-webkit-scrollbar { display: none; }
.feed-sort-tab {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-2);
font-family: inherit;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.feed-sort-tab:hover {
background: var(--j13-bg-block-muted);
color: var(--color-text-1);
}
.feed-sort-tab.active {
background: var(--j13-green-bg);
color: var(--j13-green);
font-weight: 600;
}
.feed-sort-tab svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.feed-head {
flex-wrap: wrap;
padding: 8px 12px 6px;
}
.feed-head__meta { display: none; }
.feed-toolbar {
padding: 0 12px 8px;
}
}
.board-grid-empty {
padding: 20px;
border-bottom: 1px solid var(--j13-border-light);
@@ -772,6 +936,13 @@ a:hover { text-decoration: underline; }
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
.post-body { flex: 1; min-width: 0; }
.post-title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; }
.post-pinned-icon {
display: inline-block;
vertical-align: -0.15em;
flex-shrink: 0;
color: #e53935;
stroke-width: 1.5;
}
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; }
.post-stats { display: flex; gap: 12px; font-size: 12px; color: var(--color-text-3); flex-shrink: 0; padding-top: 4px; }
@@ -857,6 +1028,497 @@ a:hover { text-decoration: underline; }
.post-detail-meta-line {
font-size: 12px;
color: var(--color-text-3);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.post-detail-locked-tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
color: hsl(var(--destructive));
font-size: 11px;
}
.post-detail-edit-hint {
font-size: 12px;
color: var(--color-text-3);
align-self: center;
}
.post-revision-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.post-revision-overlay--fullscreen {
padding: 0;
}
.post-revision-panel {
background: hsl(var(--background));
border-radius: 12px;
width: min(1080px, 100%);
height: min(88vh, 900px);
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.post-revision-panel--fullscreen {
width: 100%;
height: 100%;
max-height: none;
border-radius: 0;
}
.post-revision-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.post-revision-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.post-revision-head-left h3 {
font-size: 15px;
font-weight: 600;
margin: 0;
}
.post-revision-head-sub {
font-size: 12px;
color: var(--color-text-3);
padding-left: 8px;
border-left: 1px solid var(--j13-border-light);
}
.post-revision-head-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.post-revision-view-tabs {
display: flex;
gap: 2px;
background: hsl(var(--muted));
border-radius: 8px;
padding: 3px;
}
.post-revision-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: none;
background: none;
border-radius: 6px;
font-size: 12px;
color: var(--color-text-2);
cursor: pointer;
white-space: nowrap;
}
.post-revision-tab:hover {
color: var(--color-text-1);
}
.post-revision-tab.active {
background: hsl(var(--background));
color: var(--color-text-1);
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.post-revision-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
color: var(--color-text-3);
}
.post-revision-icon-btn:hover {
background: hsl(var(--muted));
color: var(--color-text-1);
}
.post-revision-loading,
.post-revision-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--color-text-3);
font-size: 14px;
}
.post-revision-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.post-revision-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--j13-border-light);
min-height: 0;
}
.post-revision-sidebar-label {
padding: 10px 14px 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-3);
}
.post-revision-list {
list-style: none;
margin: 0;
padding: 4px 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.post-revision-item {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
text-align: left;
padding: 10px 12px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
margin-bottom: 2px;
}
.post-revision-item:hover,
.post-revision-item.active {
background: hsl(var(--muted));
}
.post-revision-item-head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.post-revision-item-num {
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
}
.post-revision-item-title {
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-revision-item-meta {
font-size: 11px;
color: var(--color-text-3);
}
.post-revision-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
font-weight: 500;
}
.post-revision-badge--none {
background: hsl(var(--muted));
color: var(--color-text-3);
}
.post-revision-current {
padding: 12px 14px;
border-top: 1px solid var(--j13-border-light);
background: hsl(var(--muted) / 0.4);
flex-shrink: 0;
}
.post-revision-current-label {
display: block;
font-size: 11px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.post-revision-current-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-revision-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.post-revision-main-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.post-revision-main-editor {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
margin-right: 8px;
}
.post-revision-main-time {
font-size: 12px;
color: var(--color-text-3);
}
.post-revision-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding: 16px 20px 24px;
-webkit-overflow-scrolling: touch;
}
/* Diff 视图 */
.revision-diff-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.revision-diff-section h4 {
font-size: 12px;
font-weight: 600;
color: var(--color-text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 8px;
}
.revision-diff-block {
padding: 12px 14px;
background: hsl(var(--muted) / 0.35);
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.revision-diff-inline del {
background: hsl(0 70% 95%);
color: hsl(0 60% 35%);
text-decoration: line-through;
border-radius: 2px;
padding: 0 2px;
}
.revision-diff-inline ins {
background: hsl(140 60% 93%);
color: hsl(140 50% 28%);
text-decoration: none;
border-radius: 2px;
padding: 0 2px;
}
.revision-diff-unchanged {
color: var(--color-text-3);
font-size: 13px;
}
.revision-diff-stats {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.revision-diff-stat {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.revision-diff-stat--add {
background: hsl(140 60% 93%);
color: hsl(140 50% 28%);
}
.revision-diff-stat--del {
background: hsl(0 70% 95%);
color: hsl(0 60% 35%);
}
.revision-diff-pre {
margin: 0;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
font-size: 13px;
line-height: 1.55;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
overflow: hidden;
}
.revision-diff-line {
display: flex;
padding: 1px 0;
white-space: pre-wrap;
word-break: break-word;
}
.revision-diff-line--add {
background: hsl(140 60% 95%);
}
.revision-diff-line--del {
background: hsl(0 70% 96%);
}
.revision-diff-line--same {
background: hsl(var(--background));
}
.revision-diff-gutter {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-3);
user-select: none;
font-size: 12px;
}
.revision-diff-line--add .revision-diff-gutter { color: hsl(140 50% 40%); }
.revision-diff-line--del .revision-diff-gutter { color: hsl(0 60% 45%); }
.revision-diff-text {
flex: 1;
padding-right: 12px;
}
.revision-diff-line--add .revision-diff-text { color: hsl(140 45% 22%); }
.revision-diff-line--del .revision-diff-text {
color: hsl(0 55% 30%);
text-decoration: line-through;
}
/* 完整版本视图 */
.revision-full-view h4 {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.4;
}
.revision-full-tags {
font-size: 13px;
color: var(--color-text-3);
margin: 0 0 16px;
}
@media (max-width: 768px) {
.post-revision-overlay:not(.post-revision-overlay--fullscreen) {
padding: 0;
align-items: stretch;
}
.post-revision-panel:not(.post-revision-panel--fullscreen) {
width: 100%;
height: 100%;
border-radius: 0;
}
.post-revision-head {
flex-wrap: wrap;
}
.post-revision-head-actions {
width: 100%;
justify-content: space-between;
}
.post-revision-view-tabs {
flex: 1;
}
.post-revision-tab-label {
display: none;
}
.post-revision-tab.active .post-revision-tab-label {
display: inline;
}
.post-revision-body {
flex-direction: column;
}
.post-revision-sidebar {
width: 100%;
max-height: 180px;
border-right: none;
border-bottom: 1px solid var(--j13-border-light);
}
.post-revision-current {
display: none;
}
}
.post-detail-tags {
@@ -2173,112 +2835,64 @@ a.waline-comment-author:hover { color: var(--j13-green); }
color: var(--j13-green);
}
.article-tool-btn.active {
background: rgba(24, 160, 88, 0.12);
color: var(--j13-green);
}
.article-tool-btn strong {
font-size: 12px;
font-weight: 700;
}
.article-editor-modes {
display: flex;
gap: 4px;
padding: 3px;
background: var(--j13-bg-block-muted);
border-radius: 8px;
}
.article-mode-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
font-size: 12px;
cursor: pointer;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.article-mode-btn.active {
background: var(--j13-bg-surface);
color: var(--j13-green);
font-weight: 500;
box-shadow: var(--j13-shadow-soft);
}
.article-editor-panes {
display: flex;
align-items: stretch;
gap: 0;
overflow: visible;
}
.article-editor-panes--edit .article-pane--edit,
.article-editor-panes--preview .article-pane--preview {
.article-editor-body {
flex: 1;
}
.article-editor-panes--split .article-pane--edit,
.article-editor-panes--split .article-pane--preview {
flex: 1;
min-width: 0;
}
.article-editor-panes--split .article-pane--edit {
border-right: 1px solid var(--j13-border-light);
}
.article-pane {
display: flex;
flex-direction: column;
overflow: visible;
min-height: 280px;
}
.article-pane-label {
padding: 6px 0 8px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-4);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
.article-editor-content {
flex: 1;
display: flex;
flex-direction: column;
}
.article-textarea {
width: 100%;
.article-editor-content .tiptap,
.article-prosemirror {
flex: 1;
min-height: 280px;
padding: 12px 0;
border: none;
background: transparent;
resize: none;
outline: none;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
font-size: 14px;
line-height: 1.8;
color: var(--color-text-1);
overflow: hidden;
box-sizing: border-box;
}
.article-textarea::placeholder {
.article-prosemirror p.is-editor-empty:first-child::before {
color: var(--color-text-4);
font-family: var(--j13-font-family);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.article-preview {
padding: 12px 0 12px 16px;
overflow: visible;
.article-prosemirror a {
color: var(--j13-green);
text-decoration: underline;
text-underline-offset: 2px;
}
.article-editor-panes--edit .article-preview,
.article-editor-panes--preview .article-preview {
padding-left: 0;
.article-prosemirror img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 12px 0;
}
.article-preview--empty .article-preview-placeholder {
color: var(--color-text-4);
font-style: italic;
.editor-members-only {
margin: 16px 0;
}
.editor-members-only--selected {
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.35);
}
.article-editor-status {
@@ -2292,21 +2906,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
flex-shrink: 0;
}
@media (max-width: 900px) {
.article-editor-panes--split {
flex-direction: column;
}
.article-editor-panes--split .article-pane--edit {
border-right: none;
border-bottom: 1px solid var(--j13-border-light);
}
.article-preview {
padding-left: 0;
}
}
@media (max-width: 768px) {
.compose-canvas {
padding: 0 16px;
@@ -2424,6 +3023,15 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.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-form-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px 16px;
font-size: 13px;
}
.admin-form-row label { color: hsl(var(--muted-foreground)); white-space: nowrap; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }

View File

@@ -17,10 +17,46 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
export function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const pad = (n: number) => String(n).padStart(2, '0');
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
d.getFullYear() === yesterday.getFullYear()
&& d.getMonth() === yesterday.getMonth()
&& d.getDate() === yesterday.getDate()
) {
return `昨天 ${clock}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()}${clock}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${clock}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */
export function formatDateTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
export function isTimeDiffSignificant(a: string, b: string) {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (Number.isNaN(da) || Number.isNaN(db)) return false;
return Math.abs(da - db) > 60_000;
}

View File

@@ -0,0 +1,112 @@
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
export type FeedCache = {
posts: PostItem[];
postTotal: number;
page: number;
hasMore: boolean;
scrollTop: number;
};
const PREFIX = 'j13-feed-cache:';
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
return `${PREFIX}${boardId}:${keyword}:${sort}`;
}
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
try {
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
return raw ? (JSON.parse(raw) as FeedCache) : null;
} catch {
return null;
}
}
/** 保存帖子列表缓存 */
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
try {
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
} catch {
// sessionStorage 不可用时忽略
}
}
/** 清除指定筛选条件下的列表缓存 */
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
try {
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
} catch {
// ignore
}
}
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
export function clearAllFeedCache() {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
}
} catch {
// ignore
}
}

View File

@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
</div>`;
}
/** 判断 HTML 正文是否为空(忽略空段落等) */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
return (doc.body.textContent ?? '').trim().length === 0;
}
/** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
if (!html.trim()) return '';

View File

@@ -0,0 +1,77 @@
import { diffLines, diffWords } from 'diff';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
export interface PostSnapshot {
title: string;
content: string;
tags: string;
}
export interface RevisionChangeSummary {
titleChanged: boolean;
tagsChanged: boolean;
contentChanged: boolean;
hasChanges: boolean;
}
export interface DiffPart {
value: string;
added?: boolean;
removed?: boolean;
}
/** 将 HTML 正文转为适合 diff 的纯文本(保留段落换行) */
export function htmlToDiffText(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
blocks.forEach(el => {
el.prepend(doc.createTextNode('\n'));
el.append(doc.createTextNode('\n'));
});
return (doc.body.textContent ?? '')
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function summarizeChange(before: PostSnapshot, after: PostSnapshot): RevisionChangeSummary {
const titleChanged = before.title.trim() !== after.title.trim();
const tagsChanged = normalizeTags(before.tags) !== normalizeTags(after.tags);
const contentChanged = htmlToDiffText(before.content) !== htmlToDiffText(after.content);
return {
titleChanged,
tagsChanged,
contentChanged,
hasChanges: titleChanged || tagsChanged || contentChanged,
};
}
function normalizeTags(tags: string) {
return tags.split(/[,]/).map(t => t.trim()).filter(Boolean).join(',');
}
export function diffTextLines(before: string, after: string): DiffPart[] {
return diffLines(before || '', after || '');
}
export function diffTextWords(before: string, after: string): DiffPart[] {
return diffWords(before || '', after || '');
}
/** 统计 diff 片段中的增删行数 */
export function countLineChanges(parts: DiffPart[]) {
let added = 0;
let removed = 0;
for (const p of parts) {
const lines = p.value.split('\n').filter(l => l.length > 0);
if (p.added) added += lines.length;
else if (p.removed) removed += lines.length;
}
return { added, removed };
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model"
@@ -138,6 +139,27 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
})
}
// APIAdminLockPost 锁定/解锁帖子编辑
func (h *Handlers) APIAdminLockPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetEditLocked(uint(id), req.Locked); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解锁编辑"
if req.Locked {
msg = "已锁定编辑"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
}
// APIAdminPinPost 置顶/取消置顶JSON
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -264,11 +286,32 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
forum := h.Settings.ForumSettings()
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,
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"post_edit_window_hours": forum["post_edit_window_hours"],
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req struct {
PostEditWindowHours int `json:"post_edit_window_hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.SetPostEditWindowHours(req.PostEditWindowHours); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"post_edit_window_hours": req.PostEditWindowHours,
})
}
@@ -284,6 +327,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Page: page,
Size: size,
Keyword: keyword,
Sort: c.DefaultQuery("sort", "latest"),
}
items, total, err := h.Post.ListItems(q)
if err != nil {
@@ -315,11 +359,22 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
post.Content = service.RedactMembersOnlyHTML(post.Content)
}
comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
isAdmin := h.isAdmin(c)
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
editReason = h.Post.UserEditBlockReason(post, uid, isAdmin)
}
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
c.JSON(http.StatusOK, gin.H{
"post": post,
"comment_count": len(comments),
"liked": h.Post.IsLiked(uid, uint(id)),
"favorited": h.Post.IsFavorited(uid, uint(id)),
"post": post,
"comment_count": len(comments),
"liked": h.Post.IsLiked(uid, uint(id)),
"favorited": h.Post.IsFavorited(uid, uint(id)),
"can_edit": canEdit,
"edit_block_reason": editReason,
"is_edited": isEdited,
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
})
}
@@ -405,6 +460,51 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page})
}
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
func (h *Handlers) APIPostRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
revs, err := h.Post.ListRevisions(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revisions": revs})
}
// APIPostRevisionDetail 查看某个历史版本
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
post, err := h.Post.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
rev, err := h.Post.GetRevision(uint(id), uint(revID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c)
}

View File

@@ -24,6 +24,7 @@ type Handlers struct {
Filter *service.SensitiveFilter
Limiter *service.RateLimiter
Online *service.OnlineService
Settings *service.ForumSettingsService
}
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {

View File

@@ -35,7 +35,7 @@ func InitDB(dbPath string) error {
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{},
&PostLike{}, &PostFavorite{},
&PostLike{}, &PostFavorite{}, &PostRevision{}, &ForumSetting{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -42,24 +42,44 @@ type Board struct {
// Post 帖子
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
BoardID uint `gorm:"index;not null" json:"board_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags string `gorm:"size:256" json:"tags"`
Pinned bool `gorm:"default:false" json:"pinned"`
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
BoardID uint `gorm:"index;not null" json:"board_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags string `gorm:"size:256" json:"tags"`
Pinned bool `gorm:"default:false" json:"pinned"`
EditLocked bool `gorm:"default:false" json:"edit_locked"`
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Comments []Comment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
}
// PostRevision 帖子编辑历史(每次修改前保存旧版本)
type PostRevision struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags string `gorm:"size:256" json:"tags"`
CreatedAt time.Time `json:"created_at"`
Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
}
// ForumSetting 论坛全局设置(键值对)
type ForumSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"size:256" json:"value"`
}
// Comment 楼层评论
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -30,7 +30,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
authSvc := service.NewAuthService(cfg.JWTSecret, filter)
userSvc := service.NewUserService(filter)
boardSvc := service.NewBoardService()
postSvc := service.NewPostService(filter)
settingsSvc := service.NewForumSettingsService()
postSvc := service.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter)
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
onlineSvc := service.NewOnlineService()
@@ -40,6 +41,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Cfg: cfg, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Backup: backupSvc,
Filter: filter, Limiter: limiter, Online: onlineSvc,
Settings: settingsSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
@@ -75,6 +77,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts", middleware.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
api.PUT("/posts/:id", h.APIUpdatePost)
api.DELETE("/posts/:id", h.APIDeletePost)
api.GET("/posts/:id/revisions", h.APIPostRevisions)
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
api.POST("/posts/:id/like", h.APIToggleLike)
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
api.DELETE("/comments/:id", h.APIDeleteComment)
@@ -85,11 +89,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
{
adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
adminAPI.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.POST("/posts/:id/lock", h.APIAdminLockPost)
adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
adminAPI.GET("/comments", h.APIAdminComments)
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)

181
scripts/seed-test-posts.mjs Normal file
View File

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

View File

@@ -20,6 +20,10 @@ var (
ErrCommentNotFound = errors.New("评论不存在")
ErrPermissionDenied = errors.New("无权操作")
ErrBoardNotFound = errors.New("板块不存在")
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
ErrPostEditExpired = errors.New("已超过可编辑时限")
ErrRevisionNotFound = errors.New("历史版本不存在")
ErrInvalidSetting = errors.New("无效的设置值")
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)

View File

@@ -3,17 +3,19 @@ package service
import (
"errors"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
type PostService struct {
filter *SensitiveFilter
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewPostService(filter *SensitiveFilter) *PostService {
return &PostService{filter: filter}
func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *PostService {
return &PostService{filter: filter, settings: settings}
}
type PostListQuery struct {
@@ -21,12 +23,14 @@ type PostListQuery struct {
Page int
Size int
Keyword string
Sort string // latest | reply | hot
}
// PostListItem 帖子列表项(含评论数等扩展字段)
type PostListItem struct {
model.Post
CommentCount int `json:"comment_count"`
CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
}
func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error) {
@@ -42,11 +46,13 @@ func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error)
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
}
}
return items, total, nil
@@ -67,6 +73,44 @@ func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
return m
}
func (s *PostService) lastReplyMap(postIDs []uint) map[uint]*time.Time {
type row struct {
PostID uint
LastReply string
}
var rows []row
model.DB.Model(&model.Comment{}).
Select("post_id, MAX(created_at) as last_reply").
Where("post_id IN ?", postIDs).
Group("post_id").
Scan(&rows)
m := make(map[uint]*time.Time, len(rows))
for _, r := range rows {
if t, ok := parseSQLiteTime(r.LastReply); ok {
m[r.PostID] = &t
}
}
return m
}
// parseSQLiteTime 解析 SQLite 聚合查询返回的时间字符串
func parseSQLiteTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
"2006-01-02 15:04:05.999999999-07:00",
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
limit = 10
@@ -113,10 +157,37 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
var total int64
db.Count(&total)
var posts []model.Post
err := db.Order("pinned desc, id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
db = db.Order("pinned desc")
switch normalizePostSort(q.Sort) {
case "reply":
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底
db = db.Order(`(
SELECT COUNT(*) FROM comments
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
) > 0 DESC`)
db = db.Order(`(
SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
) DESC`)
db = db.Order("posts.created_at DESC")
case "hot":
db = db.Order("like_count desc, view_count desc")
default:
db = db.Order("id desc")
}
err := db.Order("id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
return posts, total, err
}
func normalizePostSort(sort string) string {
switch sort {
case "reply", "hot":
return sort
default:
return "latest"
}
}
func (s *PostService) GetByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
@@ -152,12 +223,107 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied
}
if err := s.checkEditable(&post, isAdmin); err != nil {
return err
}
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
tags = s.filter.Filter(strings.TrimSpace(tags))
return model.DB.Model(&post).Updates(map[string]interface{}{
"title": title, "content": content, "tags": tags,
}).Error
return model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{
PostID: postID, EditorID: userID,
Title: post.Title, Content: post.Content, Tags: post.Tags,
}
if err := tx.Create(&rev).Error; err != nil {
return err
}
return tx.Model(&post).Updates(map[string]interface{}{
"title": title, "content": content, "tags": tags,
}).Error
})
}
// CanEdit 判断当前用户是否可编辑帖子
func (s *PostService) CanEdit(post *model.Post, isAdmin bool) bool {
return s.checkEditable(post, isAdmin) == nil
}
// EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串)
func (s *PostService) EditBlockReason(post *model.Post, isAdmin bool) string {
if err := s.checkEditable(post, isAdmin); err != nil {
return err.Error()
}
return ""
}
func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error {
if isAdmin {
return nil
}
if post.EditLocked {
return ErrPostEditLocked
}
window := s.settings.PostEditWindowHours()
if window > 0 && time.Since(post.CreatedAt) > time.Duration(window)*time.Hour {
return ErrPostEditExpired
}
return nil
}
// CanUserEdit 判断指定用户是否可编辑帖子
func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) bool {
if userID == 0 {
return false
}
if !isAdmin && post.UserID != userID {
return false
}
return s.CanEdit(post, isAdmin)
}
// UserEditBlockReason 返回用户不可编辑的原因
func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin bool) string {
if userID == 0 {
return "请先登录"
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied.Error()
}
return s.EditBlockReason(post, isAdmin)
}
func (s *PostService) SetEditLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("edit_locked", locked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrPostNotFound
}
return nil
}
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
var revs []model.PostRevision
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
Order("id desc").Find(&revs).Error
if err != nil {
return nil, err
}
if revs == nil {
revs = []model.PostRevision{}
}
return revs, nil
}
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
var rev model.PostRevision
err := model.DB.Preload("Editor").
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
if err != nil {
return nil, ErrRevisionNotFound
}
return &rev, nil
}
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {

70
service/settings.go Normal file
View File

@@ -0,0 +1,70 @@
package service
import (
"strconv"
"sync"
"git.iioio.com/freefire/jiang13-forum/model"
)
const (
SettingPostEditWindowHours = "post_edit_window_hours"
defaultEditWindowHours = 24
)
// ForumSettingsService 论坛全局设置
type ForumSettingsService struct {
mu sync.RWMutex
}
func NewForumSettingsService() *ForumSettingsService {
s := &ForumSettingsService{}
s.ensureDefaults()
return s
}
func (s *ForumSettingsService) ensureDefaults() {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", SettingPostEditWindowHours).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{
Key: SettingPostEditWindowHours,
Value: strconv.Itoa(defaultEditWindowHours),
})
}
}
// PostEditWindowHours 返回用户可编辑帖子的时限小时0 表示不限
func (s *ForumSettingsService) PostEditWindowHours() int {
s.mu.RLock()
defer s.mu.RUnlock()
var setting model.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", SettingPostEditWindowHours).Error; err != nil {
return defaultEditWindowHours
}
h, err := strconv.Atoi(setting.Value)
if err != nil || h < 0 {
return defaultEditWindowHours
}
return h
}
// SetPostEditWindowHours 设置编辑时限小时0 表示不限
func (s *ForumSettingsService) SetPostEditWindowHours(hours int) error {
if hours < 0 {
return ErrInvalidSetting
}
s.mu.Lock()
defer s.mu.Unlock()
return model.DB.Save(&model.ForumSetting{
Key: SettingPostEditWindowHours,
Value: strconv.Itoa(hours),
}).Error
}
// ForumSettings 返回所有可配置的论坛设置
func (s *ForumSettingsService) ForumSettings() map[string]int {
return map[string]int{
"post_edit_window_hours": s.PostEditWindowHours(),
}
}