支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。

新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 17:13:32 +08:00
parent e038d4f9e9
commit 55c256654b
63 changed files with 1966 additions and 714 deletions

View File

@@ -1 +1 @@
import{e as A,r as i,j as e}from"./react-vendor-1fwFMt02.js";import{i as D,S as C,B as d,a as m,n as c}from"./index-CucHNnyZ.js";import{B as N}from"./badge-CS5odIe6.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-D0wiVaPM.js";import"./ui-vendor-Gb6mai95.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-BdyZmpPe.js";import{m as D,S as C,B as d,b as x,n as c}from"./index-Cp7Ff9NP.js";import{B as N}from"./badge-xzbTTsRq.js";import{A as y,g as b,a as k,b as S,c as _,d as z,e as B,f as E,h as P}from"./alert-dialog-C2PKamsV.js";import"./ui-vendor-B26O8baZ.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),x.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 x.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,m;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:((m=s.user)==null?void 0:m.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-1fwFMt02.js";import{i as o,a as x,S as j}from"./index-CucHNnyZ.js";import{B as u}from"./badge-CS5odIe6.js";import"./ui-vendor-Gb6mai95.js";function f(){const i=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:()=>i("/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 l;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:()=>i(`/post/${a.id}`),children:a.title})}),s.jsx("td",{children:((l=a.user)==null?void 0:l.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-BdyZmpPe.js";import{m as o,b as x,S as j}from"./index-Cp7Ff9NP.js";import{B as u}from"./badge-xzbTTsRq.js";import"./ui-vendor-B26O8baZ.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};

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{e as j,r as n,j as s}from"./react-vendor-1fwFMt02.js";import{h as f,a as g,n as N,S as v,B as m}from"./index-CucHNnyZ.js";import{f as y}from"./content-BrFHUpp8.js";import{A as k}from"./ui-vendor-Gb6mai95.js";function S(){const a=j(),{user:t,loading:i}=f(),[r,h]=n.useState([]),[x,u]=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(()=>u(!1))}},[t,i,a]),i||x?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-BdyZmpPe.js";import{h as f,b as g,n as N,S as v,B as m}from"./index-Cp7Ff9NP.js";import{f as y}from"./content-BrFHUpp8.js";import{Q as b}from"./ui-vendor-B26O8baZ.js";function S(){const a=j(),{user:t,loading:i}=f(),[r,h]=n.useState([]),[x,u]=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(()=>u(!1))}},[t,i,a]),i||x?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(b,{}),"返回"]}),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-1fwFMt02.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-cra7lKcv.js";import{h as v,B as L,a as S,n as d}from"./index-CucHNnyZ.js";import{I as u}from"./input-DOedlbiC.js";import"./ui-vendor-Gb6mai95.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-BdyZmpPe.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-DggfNWHU.js";import{h as v,B as L,b as S,n as d}from"./index-Cp7Ff9NP.js";import{I as u}from"./input-y6vZyHhN.js";import"./ui-vendor-B26O8baZ.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 r}from"./react-vendor-BdyZmpPe.js";import{i as s,c as e}from"./index-Cp7Ff9NP.js";import{ac as i}from"./ui-vendor-B26O8baZ.js";function d({board:a,className:n}){const o=s(a);return r.jsx("span",{className:e("board-badge",`board-badge--${o}`,n),children:a.name})}function l({className:a,size:n=16}){return r.jsx(i,{className:e("post-pinned-icon",a),size:n,fill:"currentColor","aria-label":"置顶",role:"img"})}export{d as B,l as P};

View File

@@ -1 +0,0 @@
import{j as n}from"./react-vendor-1fwFMt02.js";import{f as i}from"./index-CucHNnyZ.js";import{N as s}from"./ui-vendor-Gb6mai95.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

View File

@@ -1,4 +1,4 @@
import{x as Gt,r as u,j as s,e as Ht}from"./react-vendor-1fwFMt02.js";import{u as ut,a as ft,F as mt,c as L,d as F,e as T,b as Z,f as G,o as Pt,s as X}from"./form-cra7lKcv.js";import{S as et,B as P,n as z,h as Bt,u as Xt,a as H}from"./index-CucHNnyZ.js";import{I as W}from"./input-DOedlbiC.js";import{B as Yt}from"./badge-CS5odIe6.js";import{D as Vt,a as Kt,b as qt,c as Jt,d as Qt,e as $t}from"./dialog-CtQqbmck.js";import{A as te,a8 as ee,a9 as ie,X as oe,t as re,k as se}from"./ui-vendor-Gb6mai95.js";var gt=!1,E,it,ot,Y,V,Dt,K,rt,st,nt,Rt,at,ht,zt,Et;function C(){if(!gt){gt=!0;var i=navigator.userAgent,e=/(?:MSIE.(\d+\.\d+))|(?:(?:Firefox|GranParadiso|Iceweasel).(\d+\.\d+))|(?:Opera(?:.+Version.|.)(\d+\.\d+))|(?:AppleWebKit.(\d+(?:\.\d+)?))|(?:Trident\/\d+\.\d+.*rv:(\d+\.\d+))/.exec(i),t=/(Mac OS X)|(Windows)|(Linux)/.exec(i);if(at=/\b(iPhone|iP[ao]d)/.exec(i),ht=/\b(iP[ao]d)/.exec(i),nt=/Android/i.exec(i),zt=/FBAN\/\w+;/i.exec(i),Et=/Mobile/i.exec(i),Rt=!!/Win64/.exec(i),e){E=e[1]?parseFloat(e[1]):e[5]?parseFloat(e[5]):NaN,E&&document&&document.documentMode&&(E=document.documentMode);var o=/(?:Trident\/(\d+.\d+))/.exec(i);Dt=o?parseFloat(o[1])+4:E,it=e[2]?parseFloat(e[2]):NaN,ot=e[3]?parseFloat(e[3]):NaN,Y=e[4]?parseFloat(e[4]):NaN,Y?(e=/(?:Chrome\/(\d+\.\d+))/.exec(i),V=e&&e[1]?parseFloat(e[1]):NaN):V=NaN}else E=it=ot=V=Y=NaN;if(t){if(t[1]){var r=/(?:Mac OS X (\d+(?:[._]\d+)?))/.exec(i);K=r?parseFloat(r[1].replace("_",".")):!0}else K=!1;rt=!!t[2],st=!!t[3]}else K=rt=st=!1}}var ct={ie:function(){return C()||E},ieCompatibilityMode:function(){return C()||Dt>E},ie64:function(){return ct.ie()&&Rt},firefox:function(){return C()||it},opera:function(){return C()||ot},webkit:function(){return C()||Y},safari:function(){return ct.webkit()},chrome:function(){return C()||V},windows:function(){return C()||rt},osx:function(){return C()||K},linux:function(){return C()||st},iphone:function(){return C()||at},mobile:function(){return C()||at||ht||nt||Et},nativeApp:function(){return C()||zt},android:function(){return C()||nt},ipad:function(){return C()||ht}},ne=ct,ae=!!(typeof window<"u"&&window.document&&window.document.createElement),he={canUseDOM:ae},ce=he,At=ce,Mt;At.canUseDOM&&(Mt=document.implementation&&document.implementation.hasFeature&&document.implementation.hasFeature("","")!==!0);/**
import{A as Gt,r as u,j as s,e as Ht}from"./react-vendor-BdyZmpPe.js";import{u as ut,a as ft,F as mt,c as L,d as F,e as T,b as Z,f as G,o as Pt,s as X}from"./form-DggfNWHU.js";import{S as et,B as P,n as z,h as Bt,u as Xt,b as H}from"./index-Cp7Ff9NP.js";import{I as W}from"./input-y6vZyHhN.js";import{B as Yt}from"./badge-xzbTTsRq.js";import{D as Vt,a as Kt,b as qt,c as Jt,d as Qt,e as $t}from"./dialog-DXwuwWJi.js";import{Q as te,at as ee,q as ie,a1 as oe,Y as re,D as se}from"./ui-vendor-B26O8baZ.js";var gt=!1,E,it,ot,Y,V,Dt,K,rt,st,nt,Rt,at,ht,zt,Et;function C(){if(!gt){gt=!0;var i=navigator.userAgent,e=/(?:MSIE.(\d+\.\d+))|(?:(?:Firefox|GranParadiso|Iceweasel).(\d+\.\d+))|(?:Opera(?:.+Version.|.)(\d+\.\d+))|(?:AppleWebKit.(\d+(?:\.\d+)?))|(?:Trident\/\d+\.\d+.*rv:(\d+\.\d+))/.exec(i),t=/(Mac OS X)|(Windows)|(Linux)/.exec(i);if(at=/\b(iPhone|iP[ao]d)/.exec(i),ht=/\b(iP[ao]d)/.exec(i),nt=/Android/i.exec(i),zt=/FBAN\/\w+;/i.exec(i),Et=/Mobile/i.exec(i),Rt=!!/Win64/.exec(i),e){E=e[1]?parseFloat(e[1]):e[5]?parseFloat(e[5]):NaN,E&&document&&document.documentMode&&(E=document.documentMode);var o=/(?:Trident\/(\d+.\d+))/.exec(i);Dt=o?parseFloat(o[1])+4:E,it=e[2]?parseFloat(e[2]):NaN,ot=e[3]?parseFloat(e[3]):NaN,Y=e[4]?parseFloat(e[4]):NaN,Y?(e=/(?:Chrome\/(\d+\.\d+))/.exec(i),V=e&&e[1]?parseFloat(e[1]):NaN):V=NaN}else E=it=ot=V=Y=NaN;if(t){if(t[1]){var r=/(?:Mac OS X (\d+(?:[._]\d+)?))/.exec(i);K=r?parseFloat(r[1].replace("_",".")):!0}else K=!1;rt=!!t[2],st=!!t[3]}else K=rt=st=!1}}var ct={ie:function(){return C()||E},ieCompatibilityMode:function(){return C()||Dt>E},ie64:function(){return ct.ie()&&Rt},firefox:function(){return C()||it},opera:function(){return C()||ot},webkit:function(){return C()||Y},safari:function(){return ct.webkit()},chrome:function(){return C()||V},windows:function(){return C()||rt},osx:function(){return C()||K},linux:function(){return C()||st},iphone:function(){return C()||at},mobile:function(){return C()||at||ht||nt||Et},nativeApp:function(){return C()||zt},android:function(){return C()||nt},ipad:function(){return C()||ht}},ne=ct,ae=!!(typeof window<"u"&&window.document&&window.document.createElement),he={canUseDOM:ae},ce=he,At=ce,Mt;At.canUseDOM&&(Mt=document.implementation&&document.implementation.hasFeature&&document.implementation.hasFeature("","")!==!0);/**
* Checks if an event is supported in the current execution environment.
*
* NOTE: This will not work correctly for non-generic events such as `change`,

View File

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

View File

@@ -1 +1 @@
import{r as l,j as s}from"./react-vendor-1fwFMt02.js";import{aj as x,ak as g,al as N,am as r,an as i,ao as d,ap as n,aq as m,ar as c}from"./ui-vendor-Gb6mai95.js";import{f as o,k as f}from"./index-CucHNnyZ.js";const C=x,T=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(r,{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=r.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(d,{ref:t,className:o("text-sm text-muted-foreground",a),...e}));w.displayName=d.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};
import{r as l,j as s}from"./react-vendor-BdyZmpPe.js";import{aD as x,aE as g,aF as N,aG as r,aH as i,aI as d,aJ as n,aK as m,aL as c}from"./ui-vendor-B26O8baZ.js";import{c as o,r as f}from"./index-Cp7Ff9NP.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(r,{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=r.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(d,{ref:t,className:o("text-sm text-muted-foreground",a),...e}));w.displayName=d.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,u as a,A as b,j as c,w as d,D as e,R as f,h as g,b as h};

View File

@@ -1 +1 @@
import{j as n}from"./react-vendor-1fwFMt02.js";import{f as a,j as o}from"./index-CucHNnyZ.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-BdyZmpPe.js";import{c as a,q as o}from"./index-Cp7Ff9NP.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 +0,0 @@
import{r as l,j as t}from"./react-vendor-1fwFMt02.js";import{ab as f,ac as p,ad as d,ae as g,X as x,af as i,ag as n,ah as r}from"./ui-vendor-Gb6mai95.js";import{f as o}from"./index-CucHNnyZ.js";const R=f,u=p,c=l.forwardRef(({className:a,...e},s)=>t.jsx(r,{ref:s,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}));c.displayName=r.displayName;const y=l.forwardRef(({className:a,children:e,...s},m)=>t.jsxs(u,{children:[t.jsx(c,{}),t.jsxs(d,{ref:m,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),...s,children:[e,t.jsxs(g,{className:"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",children:[t.jsx(x,{className:"h-4 w-4"}),t.jsx("span",{className:"sr-only",children:"关闭"})]})]})]}));y.displayName=d.displayName;const N=({className:a,...e})=>t.jsx("div",{className:o("flex flex-col space-y-1.5 text-center sm:text-left",a),...e});N.displayName="DialogHeader";const j=({className:a,...e})=>t.jsx("div",{className:o("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",a),...e});j.displayName="DialogFooter";const D=l.forwardRef(({className:a,...e},s)=>t.jsx(i,{ref:s,className:o("text-lg font-semibold leading-none tracking-tight",a),...e}));D.displayName=i.displayName;const b=l.forwardRef(({className:a,...e},s)=>t.jsx(n,{ref:s,className:o("text-sm text-muted-foreground",a),...e}));b.displayName=n.displayName;export{R as D,y as a,N as b,D as c,b as d,j as e};

View File

@@ -0,0 +1 @@
import{r as l,j as t}from"./react-vendor-BdyZmpPe.js";import{av as f,aw as p,ax as d,ay as g,a1 as x,az as i,aA as n,aB as r}from"./ui-vendor-B26O8baZ.js";import{c as o}from"./index-Cp7Ff9NP.js";const R=f,u=p,c=l.forwardRef(({className:a,...e},s)=>t.jsx(r,{ref:s,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}));c.displayName=r.displayName;const y=l.forwardRef(({className:a,children:e,...s},m)=>t.jsxs(u,{children:[t.jsx(c,{}),t.jsxs(d,{ref:m,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),...s,children:[e,t.jsxs(g,{className:"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",children:[t.jsx(x,{className:"h-4 w-4"}),t.jsx("span",{className:"sr-only",children:"关闭"})]})]})]}));y.displayName=d.displayName;const N=({className:a,...e})=>t.jsx("div",{className:o("flex flex-col space-y-1.5 text-center sm:text-left",a),...e});N.displayName="DialogHeader";const j=({className:a,...e})=>t.jsx("div",{className:o("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",a),...e});j.displayName="DialogFooter";const D=l.forwardRef(({className:a,...e},s)=>t.jsx(i,{ref:s,className:o("text-lg font-semibold leading-none tracking-tight",a),...e}));D.displayName=i.displayName;const b=l.forwardRef(({className:a,...e},s)=>t.jsx(n,{ref:s,className:o("text-sm text-muted-foreground",a),...e}));b.displayName=n.displayName;export{R as D,y as a,N as b,D as c,b as d,j as e};

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -27,10 +27,10 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-CucHNnyZ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-1fwFMt02.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-Gb6mai95.js">
<link rel="stylesheet" crossorigin href="/assets/index-2yIymxOj.css">
<script type="module" crossorigin src="/assets/index-Cp7Ff9NP.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-BdyZmpPe.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-B26O8baZ.js">
<link rel="stylesheet" crossorigin href="/assets/index-xhm8HZjc.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,11 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import {
createBrowserRouter,
createRoutesFromElements,
RouterProvider,
Route,
Navigate,
} from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
@@ -23,13 +29,9 @@ const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
@@ -50,8 +52,16 @@ export default function App() {
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</Routes>
</BrowserRouter>
</>,
),
);
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<Toaster />
</ErrorBoundary>
</AuthProvider>

View File

@@ -43,9 +43,9 @@ export const api = {
online: () => request<OnlineStats>('/api/online'),
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
createBoard: (body: { name: string; description: string; sort_order: number }) =>
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
updateBoard: (id: number, body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
// 管理后台 API

View File

@@ -12,6 +12,8 @@ export interface Board {
id: number;
name: string;
description: string;
icon?: string;
color_index?: number;
sort_order: number;
post_count?: number;
}

View File

@@ -0,0 +1,93 @@
import { cn } from '@/lib/utils';
import {
BOARD_ICON_OPTIONS,
BOARD_PALETTE_SIZE,
getBoardIcon,
getBoardThemeIndex,
} from '../utils/boardTheme';
import type { Board } from '../api/types';
interface IconPickerProps {
value: string;
onChange: (key: string) => void;
board: Pick<Board, 'id' | 'color_index'>;
}
interface ColorPickerProps {
value: number;
onChange: (index: number) => void;
boardId: number;
}
/** 板块图标选择器 */
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
const PreviewIcon = getBoardIcon(previewBoard);
const themeIdx = getBoardThemeIndex(previewBoard);
return (
<div className="board-appearance-picker">
<div className="board-appearance-picker__preview">
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
<PreviewIcon aria-hidden />
</span>
<span className="board-appearance-picker__preview-hint"></span>
</div>
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
<button
type="button"
role="option"
aria-selected={!value}
className={cn('board-icon-option', !value && 'board-icon-option--active')}
title="自动(按板块 ID"
onClick={() => onChange('')}
>
<span className="board-icon-option__auto">A</span>
</button>
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
role="option"
aria-selected={value === key}
aria-label={label}
title={label}
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
</button>
))}
</div>
</div>
);
}
/** 板块色标选择器(-1 为自动) */
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
return (
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
<button
type="button"
role="option"
aria-selected={value < 0}
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
title="自动(按板块 ID"
onClick={() => onChange(-1)}
>
A
</button>
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
<button
key={i}
type="button"
role="option"
aria-selected={value === i}
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
title={`色标 ${i + 1}`}
onClick={() => onChange(i)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import type { Board } from '../api/types';
interface Props {
board: Pick<Board, 'id' | 'name' | 'color_index'>;
className?: string;
}
/** 按板块配置或 id 映射不同色标的 Badge */
export default function BoardBadge({ board, className }: Props) {
const idx = getBoardThemeIndex(board);
return (
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
{board.name}
</span>
);
}

View File

@@ -0,0 +1,14 @@
import { getBoardIcon } from '../utils/boardTheme';
import type { Board } from '../api/types';
/** 渲染板块 Lucide 图标 */
export default function BoardIconDisplay({
board,
className,
}: {
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
className?: string;
}) {
const Icon = getBoardIcon(board);
return <Icon className={className} aria-hidden />;
}

View File

@@ -1,3 +1,4 @@
import { Users, FileText, LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
@@ -24,13 +25,20 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<span className="feed-head__meta">
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />
<strong>{stats.users}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
</span>
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{stats.posts}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
</span>
<span className="feed-stat-chip">
<LayoutGrid aria-hidden />
<strong>{stats.boards}</strong>
</span>
</div>
)}
</div>
{keyword && (

View File

@@ -1,4 +1,5 @@
import { Badge } from '@/components/ui/badge';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -17,6 +18,8 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
return (
<div className="post-row" onClick={onClick}>
@@ -29,14 +32,20 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
{post.board && <BoardBadge board={post.board} />}
<span>{post.user?.nickname || '匿名'}</span>
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">
<span>💬 {post.comment_count ?? 0}</span>
<span>👍 {post.like_count ?? 0}</span>
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
<ThumbsUp aria-hidden />
{likeCount}
</span>
</div>
</div>
);

View File

@@ -0,0 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton';
interface Props {
count?: number;
}
/** 帖子列表加载骨架屏 */
export default function PostListSkeleton({ count = 8 }: Props) {
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
<div className="skeleton-meta-row">
<Skeleton className="skeleton--badge" />
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
</div>
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
))}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { Flame, Megaphone, Users } from 'lucide-react';
import type { PostItem, Notification, OnlineStats } from '../api/types';
interface Props {
@@ -7,45 +8,61 @@ interface Props {
onPostClick: (id: number) => void;
}
function hotRankClass(index: number): string {
if (index === 0) return 'widget-rank widget-rank--1';
if (index === 1) return 'widget-rank widget-rank--2';
if (index === 2) return 'widget-rank widget-rank--3';
return 'widget-rank';
}
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? [];
return (
<>
<div className="aside-panel-inner">
<div className="widget-card">
<div className="widget-card-head">🔥 </div>
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{hotList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">📢 </div>
<div className="widget-card-head">
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{noticeList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
<div className="widget-empty"></div>
) : noticeList.map(item => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-time">{item.created_at}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">👀 {online?.count ?? '—'} </div>
<div className="widget-card-head">
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
<span className="widget-head-count">{online?.count ?? '—'}</span>
</div>
<div className="widget-card-body">
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
<div className="widget-online-meta">
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div className="widget-online-list">
@@ -57,7 +74,7 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</span>
))}
{members.length === 0 && (
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>线</span>
<span className="widget-empty widget-empty--inline">线</span>
)}
</div>
</div>
@@ -71,6 +88,6 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</p>
</div>
</div>
</>
</div>
);
}

View File

@@ -7,6 +7,8 @@ import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -60,18 +62,34 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
{boards.length > 0 && (
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav">
{boards.map(b => (
{boards.map(b => {
const isActive = menuKey != null && menuKey === String(b.id);
const themeIdx = getBoardThemeIndex(b);
return (
<button
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
className={cn(
'sidebar-nav-item',
'sidebar-nav-item--board',
isActive && 'active',
isActive && `sidebar-nav-item--board-${themeIdx}`,
)}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<BoardIconDisplay
board={b}
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta">{b.post_count}</span>
)}
</button>
))}
);
})}
</nav>
</>
)}

View File

@@ -0,0 +1,45 @@
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface Props {
open: boolean;
onStay: () => void;
onLeave: () => void;
/** 编辑已有帖子时为 true */
isEdit?: boolean;
}
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
return (
<AlertDialog open={open}>
<AlertDialogContent onEscapeKeyDown={onStay}>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{isEdit
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onStay}></AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={onLeave}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,8 +1,8 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -50,6 +50,8 @@ export default function VirtualPostList({
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
@@ -88,6 +90,10 @@ export default function VirtualPostList({
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
@@ -107,11 +113,7 @@ export default function VirtualPostList({
);
})}
</div>
{loading && (
<div className="feed-list-footer">
<Spinner />
</div>
)}
{isLoadingMore && <PostListSkeleton count={2} />}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
@@ -125,6 +127,8 @@ export default function VirtualPostList({
{showEnd && (
<div className="feed-list-footer feed-list-footer--end"> </div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('skeleton', className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
interface Options {
/** 是否存在未保存的修改 */
isDirty: boolean;
}
/**
* 拦截页面内导航与关闭标签页,在存在未保存修改时提示用户确认。
*/
export function useUnsavedChangesGuard({ isDirty }: Options) {
const [dialogOpen, setDialogOpen] = useState(false);
const pendingLeaveRef = useRef<(() => void) | null>(null);
/** 同步标记,避免 setState 未及时生效导致 useBlocker 二次拦截 */
const allowNavigationRef = useRef(false);
const shouldBlock = isDirty && !allowNavigationRef.current;
const blocker = useBlocker(() => isDirty && !allowNavigationRef.current);
useEffect(() => {
if (blocker.state === 'blocked') {
setDialogOpen(true);
}
}, [blocker.state]);
useEffect(() => {
if (!shouldBlock) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [shouldBlock]);
const stayOnPage = useCallback(() => {
setDialogOpen(false);
pendingLeaveRef.current = null;
if (blocker.state === 'blocked') {
blocker.reset?.();
}
}, [blocker]);
const discardAndLeave = useCallback(() => {
setDialogOpen(false);
allowNavigationRef.current = true;
if (blocker.state === 'blocked') {
blocker.proceed?.();
return;
}
const action = pendingLeaveRef.current;
pendingLeaveRef.current = null;
action?.();
}, [blocker]);
/** 主动发起离开(如点击返回按钮) */
const requestLeave = useCallback((action: () => void) => {
if (!shouldBlock) {
action();
return;
}
pendingLeaveRef.current = action;
setDialogOpen(true);
}, [shouldBlock]);
/** 保存成功后调用,允许后续导航不再拦截 */
const markSaved = useCallback(() => {
allowNavigationRef.current = true;
}, []);
return {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
};
}

View File

@@ -20,6 +20,8 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -221,13 +223,21 @@ export default function MainLayout() {
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
></span>
{boards.map(b => (
{boards.map(b => {
const themeIdx = getBoardThemeIndex(b);
const isActive = mobileActiveBoard === b.id;
return (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
className={cn(
'board-chip',
isActive && 'active',
isActive && `board-chip--${themeIdx}`,
)}
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
))}
);
})}
</div>
)}
<Suspense fallback={<PageLoader />}>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -24,20 +23,25 @@ import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client';
import { useAdminGuard } from '../layouts/AdminLayout';
import type { Board } from '../api/types';
import { BoardColorPicker, BoardIconPicker } from '../components/BoardAppearancePicker';
import BoardIconDisplay from '../components/BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0),
icon: z.string().max(64).optional(),
color_index: z.coerce.number().min(-1).max(7),
});
type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
@@ -47,9 +51,12 @@ export default function BoardsManagePage() {
const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 },
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
});
const watchColorIndex = form.watch('color_index');
const editingPreviewId = editing?.id ?? boards.length + 1;
const load = () => {
setLoading(true);
api.boards()
@@ -64,7 +71,7 @@ export default function BoardsManagePage() {
const openCreate = () => {
setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
form.reset({ name: '', description: '', sort_order: boards.length + 1, icon: '', color_index: -1 });
setModalOpen(true);
};
@@ -74,6 +81,8 @@ export default function BoardsManagePage() {
name: board.name,
description: board.description ?? '',
sort_order: board.sort_order,
icon: board.icon ?? '',
color_index: board.color_index ?? -1,
});
setModalOpen(true);
};
@@ -81,11 +90,18 @@ export default function BoardsManagePage() {
const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true);
try {
const body = {
name: values.name,
description: values.description ?? '',
sort_order: values.sort_order,
icon: values.icon ?? '',
color_index: values.color_index ?? -1,
};
if (editing) {
await api.updateBoard(editing.id, values);
await api.updateBoard(editing.id, body);
notify.success('板块已更新');
} else {
await api.createBoard(values);
await api.createBoard(body);
notify.success('板块已创建');
}
setModalOpen(false);
@@ -119,7 +135,7 @@ export default function BoardsManagePage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1></h1>
<p></p>
<p></p>
</div>
<Button onClick={openCreate}>
<Plus />
@@ -137,6 +153,7 @@ export default function BoardsManagePage() {
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[52px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead>
@@ -145,9 +162,16 @@ export default function BoardsManagePage() {
</TableRow>
</TableHeader>
<TableBody>
{boards.map(board => (
{boards.map(board => {
const themeIdx = getBoardThemeIndex(board);
return (
<TableRow key={board.id}>
<TableCell>{board.id}</TableCell>
<TableCell>
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
<BoardIconDisplay board={board} />
</span>
</TableCell>
<TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell>
@@ -179,7 +203,8 @@ export default function BoardsManagePage() {
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
{boards.length === 0 && (
@@ -192,7 +217,7 @@ export default function BoardsManagePage() {
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogContent className="board-manage-dialog">
<DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader>
@@ -224,6 +249,40 @@ export default function BoardsManagePage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardIconPicker
value={field.value ?? ''}
onChange={field.onChange}
board={{ id: editingPreviewId, color_index: watchColorIndex }}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color_index"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardColorPicker
value={field.value ?? -1}
onChange={field.onChange}
boardId={editingPreviewId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify';
@@ -7,9 +7,18 @@ import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import { useForumLimits } from '../hooks/useForumLimits';
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
import ArticleEditor from '../components/ArticleEditor';
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
import { Spinner } from '@/components/ui/spinner';
interface ComposeBaseline {
title: string;
tags: string;
content: string;
boardId: string;
}
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
@@ -27,6 +36,7 @@ export default function ComposePage() {
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
useEffect(() => {
if (authLoading) return;
@@ -50,10 +60,17 @@ export default function ComposePage() {
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
const loadedBoardId = String(post.board_id);
setBoardId(loadedBoardId);
setTitle(post.title);
setTags(post.tags ?? '');
setContent(post.content ?? '');
setBaseline({
title: post.title,
tags: post.tags ?? '',
content: post.content ?? '',
boardId: loadedBoardId,
});
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -66,12 +83,37 @@ export default function ComposePage() {
api.boards().then(d => {
const list = d.boards ?? [];
setBoards(list);
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id));
setBoardId(initialBoardId);
}
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
const isDirty = useMemo(() => {
if (!baseline) return false;
return (
title !== baseline.title
|| tags !== baseline.tags
|| content !== baseline.content
|| (!isEdit && boardId !== baseline.boardId)
);
}, [baseline, title, tags, content, boardId, isEdit]);
const {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
} = useUnsavedChangesGuard({ isDirty });
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
@@ -127,10 +169,12 @@ export default function ComposePage() {
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
markSaved();
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
markSaved();
nav(`/post/${res.post_id}`);
}
} catch (e: unknown) {
@@ -146,7 +190,11 @@ export default function ComposePage() {
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
>
<ArrowLeft size={16} />
<span></span>
</button>
@@ -215,6 +263,12 @@ export default function ComposePage() {
/>
</div>
</div>
<UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</div>
);
}

View File

@@ -183,6 +183,7 @@ export default function HomePage() {
return (
<div className="page-wrap" ref={pageWrapRef}>
<div className="feed-panel">
<div className="feed-top">
<FeedHeader
boardId={boardId}
@@ -210,5 +211,6 @@ export default function HomePage() {
onScrollRestored={() => setRestoreScrollTop(null)}
/>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-re
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -214,7 +215,7 @@ export default function PostDetailPage() {
</Button>
{post.board && (
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge>
<BoardBadge board={post.board} className="post-detail-board-tag" />
)}
</div>

View File

@@ -87,6 +87,23 @@
--j13-bg-block-accent: #f0faf5;
--j13-shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.04);
--j13-shadow-card: 0 2px 8px rgba(15, 23, 42, 0.05);
/* 板块色标8 色槽,按 board id 取模) */
--board-0-color: #18a058;
--board-0-bg: rgba(24, 160, 88, 0.1);
--board-1-color: #3498db;
--board-1-bg: rgba(52, 152, 219, 0.1);
--board-2-color: #9b59b6;
--board-2-bg: rgba(155, 89, 182, 0.1);
--board-3-color: #e67e22;
--board-3-bg: rgba(230, 126, 34, 0.1);
--board-4-color: #e74c3c;
--board-4-bg: rgba(231, 76, 60, 0.1);
--board-5-color: #1abc9c;
--board-5-bg: rgba(26, 188, 156, 0.1);
--board-6-color: #6366f1;
--board-6-bg: rgba(99, 102, 241, 0.1);
--board-7-color: #f59e0b;
--board-7-bg: rgba(245, 158, 11, 0.12);
}
.dark {
@@ -104,6 +121,28 @@
--j13-bg-block-accent: rgba(35, 195, 107, 0.06);
--j13-shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.2);
--j13-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.28);
--board-0-color: #23c36b;
--board-0-bg: rgba(35, 195, 107, 0.14);
--board-1-color: #5dade2;
--board-1-bg: rgba(93, 173, 226, 0.14);
--board-2-color: #bb86fc;
--board-2-bg: rgba(187, 134, 252, 0.14);
--board-3-color: #f0a050;
--board-3-bg: rgba(240, 160, 80, 0.14);
--board-4-color: #ff7b72;
--board-4-bg: rgba(255, 123, 114, 0.14);
--board-5-color: #3dd6b5;
--board-5-bg: rgba(61, 214, 181, 0.14);
--board-6-color: #818cf8;
--board-6-bg: rgba(129, 140, 248, 0.14);
--board-7-color: #fbbf24;
--board-7-bg: rgba(251, 191, 36, 0.14);
}
.dark .app-shell {
background:
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(35, 195, 107, 0.08), transparent 70%),
var(--j13-bg-page);
}
* { box-sizing: border-box; }
@@ -141,7 +180,9 @@ a:hover { text-decoration: underline; }
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--j13-bg-page);
background:
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(24, 160, 88, 0.07), transparent 70%),
var(--j13-bg-page);
}
/* 顶栏 + 主体共用同一宽度容器,顶满高度、直角外框 */
@@ -483,6 +524,36 @@ a:hover { text-decoration: underline; }
font-size: 12px;
}
.sidebar-nav-item--board .sidebar-board-icon {
opacity: 0.88;
}
.sidebar-nav-item--board.active .sidebar-board-icon {
opacity: 1;
}
.sidebar-nav-item--board.active.sidebar-nav-item--board-0 { background: var(--board-0-bg); color: var(--board-0-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-1 { background: var(--board-1-bg); color: var(--board-1-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-2 { background: var(--board-2-bg); color: var(--board-2-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-3 { background: var(--board-3-bg); color: var(--board-3-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-4 { background: var(--board-4-bg); color: var(--board-4-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-5 { background: var(--board-5-bg); color: var(--board-5-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-6 { background: var(--board-6-bg); color: var(--board-6-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-7 { background: var(--board-7-bg); color: var(--board-7-color); }
.sidebar-board-icon--0 { color: var(--board-0-color); }
.sidebar-board-icon--1 { color: var(--board-1-color); }
.sidebar-board-icon--2 { color: var(--board-2-color); }
.sidebar-board-icon--3 { color: var(--board-3-color); }
.sidebar-board-icon--4 { color: var(--board-4-color); }
.sidebar-board-icon--5 { color: var(--board-5-color); }
.sidebar-board-icon--6 { color: var(--board-6-color); }
.sidebar-board-icon--7 { color: var(--board-7-color); }
.sidebar-section--boards {
margin-top: 8px;
}
.sidebar-actions {
padding: 12px;
border-top: 1px solid var(--j13-border);
@@ -496,6 +567,7 @@ a:hover { text-decoration: underline; }
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--j13-bg-workspace);
}
/* 主内容 + 右栏:与左栏同色,整块平铺 */
@@ -516,7 +588,15 @@ a:hover { text-decoration: underline; }
padding: 0;
display: flex;
flex-direction: column;
background: var(--j13-bg-surface);
background: var(--j13-bg-workspace);
}
.aside-panel-inner {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
flex: 1;
}
@media (max-width: 1100px) { .aside-panel { display: none; } }
@@ -540,6 +620,19 @@ a:hover { text-decoration: underline; }
flex-direction: column;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 12px;
}
.feed-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--j13-border-light);
border-radius: 12px;
background: var(--j13-bg-block);
box-shadow: var(--j13-shadow-card);
overflow: hidden;
}
.page-wrap::-webkit-scrollbar {
@@ -597,6 +690,13 @@ a:hover { text-decoration: underline; }
overflow: hidden;
}
.feed-panel .content-surface {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.section-card {
background: var(--j13-bg-block);
border: 1px solid var(--j13-border-light);
@@ -618,6 +718,19 @@ a:hover { text-decoration: underline; }
flex-shrink: 0;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block);
position: relative;
}
.feed-top::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, var(--j13-green) 0%, transparent 55%);
opacity: 0.35;
pointer-events: none;
}
@media (min-width: 900px) {
@@ -675,6 +788,37 @@ a:hover { text-decoration: underline; }
text-overflow: ellipsis;
}
.feed-head__stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.feed-stat-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
white-space: nowrap;
}
.feed-stat-chip svg {
width: 12px;
height: 12px;
color: var(--j13-green);
opacity: 0.85;
}
.feed-stat-chip strong {
color: var(--j13-green);
font-weight: 600;
}
.feed-head__meta strong {
color: var(--j13-green);
font-weight: 600;
@@ -783,6 +927,15 @@ a:hover { text-decoration: underline; }
font-weight: 600;
}
.board-chip.active.board-chip--0 { background: var(--board-0-bg); color: var(--board-0-color); }
.board-chip.active.board-chip--1 { background: var(--board-1-bg); color: var(--board-1-color); }
.board-chip.active.board-chip--2 { background: var(--board-2-bg); color: var(--board-2-color); }
.board-chip.active.board-chip--3 { background: var(--board-3-bg); color: var(--board-3-color); }
.board-chip.active.board-chip--4 { background: var(--board-4-bg); color: var(--board-4-color); }
.board-chip.active.board-chip--5 { background: var(--board-5-bg); color: var(--board-5-color); }
.board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); }
.board-grid {
display: flex;
flex-wrap: wrap;
@@ -845,11 +998,22 @@ a:hover { text-decoration: underline; }
padding: 8px 12px 6px;
}
.feed-head__meta { display: none; }
.feed-head__meta,
.feed-head__stats { display: none; }
.feed-toolbar {
padding: 0 12px 8px;
}
.page-wrap {
padding: 0;
}
.feed-panel {
border: none;
border-radius: 0;
box-shadow: none;
}
}
.board-grid-empty {
@@ -951,10 +1115,34 @@ a:hover { text-decoration: underline; }
padding: 12px 20px;
border-bottom: 1px solid var(--j13-border-light);
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, box-shadow 0.15s;
position: relative;
}
.post-row:hover { background: var(--j13-bg-block-muted); }
.post-row::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--j13-green);
opacity: 0;
transition: opacity 0.15s;
}
.post-row:hover {
background: var(--j13-bg-block-accent);
}
.post-row:hover::before {
opacity: 1;
}
.post-row:hover .post-title {
color: var(--j13-green);
}
.post-avatar {
width: 36px;
@@ -969,11 +1157,26 @@ a:hover { text-decoration: underline; }
justify-content: center;
flex-shrink: 0;
overflow: hidden;
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-border);
transition: box-shadow 0.15s;
}
.post-row:hover .post-avatar {
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
}
.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-title {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
color: var(--color-text-1);
transition: color 0.15s;
}
.post-pinned-icon {
display: inline-block;
vertical-align: -0.15em;
@@ -981,8 +1184,266 @@ a:hover { text-decoration: underline; }
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; }
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.post-stats {
display: flex;
gap: 8px;
flex-shrink: 0;
padding-top: 2px;
}
.post-stat {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
transition: background 0.15s, color 0.15s;
}
.post-stat svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.post-row:hover .post-stat {
background: var(--j13-green-bg);
color: var(--j13-green);
}
.post-stat--zero {
opacity: 0.55;
}
/* 板块色标 Badge */
.board-badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.board-badge--0 { background: var(--board-0-bg); color: var(--board-0-color); }
.board-badge--1 { background: var(--board-1-bg); color: var(--board-1-color); }
.board-badge--2 { background: var(--board-2-bg); color: var(--board-2-color); }
.board-badge--3 { background: var(--board-3-bg); color: var(--board-3-color); }
.board-badge--4 { background: var(--board-4-bg); color: var(--board-4-color); }
.board-badge--5 { background: var(--board-5-bg); color: var(--board-5-color); }
.board-badge--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-badge--7 { background: var(--board-7-bg); color: var(--board-7-color); }
/* 板块管理:图标 / 色标选择器 */
.board-manage-dialog {
max-width: 520px;
}
.board-appearance-picker__preview {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.board-appearance-picker__preview-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-bg-block-muted);
}
.board-appearance-picker__preview-icon svg {
width: 20px;
height: 20px;
}
.board-appearance-picker__preview-hint {
font-size: 12px;
color: var(--color-text-3);
}
.board-icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
}
.board-icon-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
background: var(--j13-bg-block);
color: var(--color-text-2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.board-icon-option svg {
width: 18px;
height: 18px;
}
.board-icon-option:hover {
border-color: var(--j13-green);
background: var(--j13-bg-block-muted);
}
.board-icon-option--active {
border-color: var(--j13-green);
background: var(--j13-green-bg);
color: var(--j13-green);
}
.board-icon-option__auto {
font-size: 12px;
font-weight: 700;
color: var(--color-text-3);
}
.board-color-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.board-color-option {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s, box-shadow 0.12s;
}
.board-color-option:hover {
transform: scale(1.08);
}
.board-color-option--active {
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
}
.board-color-option--auto {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-bg-block-muted);
font-size: 11px;
font-weight: 700;
color: var(--color-text-3);
}
.board-color-option--0 { background: var(--board-0-color); }
.board-color-option--1 { background: var(--board-1-color); }
.board-color-option--2 { background: var(--board-2-color); }
.board-color-option--3 { background: var(--board-3-color); }
.board-color-option--4 { background: var(--board-4-color); }
.board-color-option--5 { background: var(--board-5-color); }
.board-color-option--6 { background: var(--board-6-color); }
.board-color-option--7 { background: var(--board-7-color); }
.board-table-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--j13-bg-block-muted);
}
.board-table-icon svg {
width: 18px;
height: 18px;
}
/* 帖子列表骨架屏 */
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--j13-bg-block-muted) 0%,
var(--j13-bg-block) 45%,
var(--j13-bg-block-muted) 90%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 6px;
}
.post-row--skeleton {
pointer-events: none;
cursor: default;
}
.post-row--skeleton::before {
display: none;
}
.post-row--skeleton:hover {
background: transparent;
}
.skeleton--avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton--title {
height: 14px;
margin-bottom: 8px;
max-width: 100%;
}
.skeleton-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.skeleton--badge {
width: 52px;
height: 18px;
border-radius: 4px;
flex-shrink: 0;
}
.skeleton--meta {
height: 12px;
width: 56px;
flex-shrink: 0;
}
.skeleton--meta-short {
width: 72px;
}
.skeleton--stat {
width: 36px;
height: 22px;
border-radius: 999px;
}
.empty-state { text-align: center; padding: 60px 24px; color: var(--color-text-3); }
@@ -2492,26 +2953,43 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.widget-card {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
background: var(--j13-bg-block);
border: 1px solid var(--j13-border-light);
border-radius: 10px;
box-shadow: var(--j13-shadow-soft);
margin-bottom: 0;
overflow: hidden;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.widget-card-head {
padding: 12px 16px 8px;
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px 8px;
font-size: 13px;
font-weight: 600;
border-bottom: none;
background: transparent;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block-muted);
color: var(--color-text-2);
}
.widget-card-body { padding: 0 16px 14px; }
.widget-card-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.widget-card-icon--hot { color: #e74c3c; }
.widget-card-icon--notice { color: #3498db; }
.widget-card-icon--online { color: var(--j13-green); }
.widget-head-count {
color: var(--j13-green);
font-weight: 700;
}
.widget-card-body { padding: 6px 14px 12px; }
.widget-card--about {
flex: 1;
@@ -2545,17 +3023,85 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.widget-item {
padding: 6px 0;
padding: 7px 0;
font-size: 13px;
cursor: pointer;
border-bottom: 1px solid var(--j13-border-light);
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
border-radius: 4px;
transition: background 0.12s, color 0.12s;
}
.widget-item:last-child { border-bottom: none; }
.widget-item:hover { color: var(--j13-green); }
.widget-item:hover {
color: var(--j13-green);
background: var(--j13-bg-block-accent);
padding-left: 6px;
padding-right: 6px;
margin-left: -6px;
margin-right: -6px;
}
.widget-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-item-time {
font-size: 11px;
color: var(--color-text-4);
flex-shrink: 0;
}
.widget-rank {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
flex-shrink: 0;
}
.widget-rank--1 {
color: #fff;
background: linear-gradient(135deg, #e74c3c, #c0392b);
}
.widget-rank--2 {
color: #fff;
background: linear-gradient(135deg, #e67e22, #d35400);
}
.widget-rank--3 {
color: #fff;
background: linear-gradient(135deg, #f1c40f, #e67e22);
}
.widget-empty {
font-size: 13px;
color: var(--color-text-3);
padding: 8px 0;
}
.widget-empty--inline {
padding: 0;
}
.widget-online-meta {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 8px;
}
.widget-online-list {
display: flex;

View File

@@ -0,0 +1,71 @@
import {
Code2, Coffee, HelpCircle, MessageSquare, Lightbulb,
BookOpen, Gamepad2, Palette, Music, Camera, Heart, Zap,
Globe, Users, Briefcase, GraduationCap, ShoppingBag, MapPin,
Megaphone, Flame, Star, Folder, Wrench, Cpu, type LucideIcon,
} from 'lucide-react';
import type { Board } from '../api/types';
/** 板块主题色槽位数(与 global.css 中 board-badge--N 对应) */
export const BOARD_PALETTE_SIZE = 8;
/** 可选板块图标key 须与后端 AllowedBoardIcons 一致) */
export const BOARD_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
{ key: 'code-2', label: '代码', Icon: Code2 },
{ key: 'coffee', label: '咖啡', Icon: Coffee },
{ key: 'help-circle', label: '问答', Icon: HelpCircle },
{ key: 'message-square', label: '讨论', Icon: MessageSquare },
{ key: 'lightbulb', label: '灵感', Icon: Lightbulb },
{ key: 'book-open', label: '阅读', Icon: BookOpen },
{ key: 'gamepad-2', label: '游戏', Icon: Gamepad2 },
{ key: 'palette', label: '设计', Icon: Palette },
{ key: 'music', label: '音乐', Icon: Music },
{ key: 'camera', label: '摄影', Icon: Camera },
{ key: 'heart', label: '生活', Icon: Heart },
{ key: 'zap', label: '快讯', Icon: Zap },
{ key: 'globe', label: '综合', Icon: Globe },
{ key: 'users', label: '社区', Icon: Users },
{ key: 'briefcase', label: '职场', Icon: Briefcase },
{ key: 'graduation-cap', label: '学习', Icon: GraduationCap },
{ key: 'shopping-bag', label: '交易', Icon: ShoppingBag },
{ key: 'map-pin', label: '本地', Icon: MapPin },
{ key: 'megaphone', label: '公告', Icon: Megaphone },
{ key: 'flame', label: '热门', Icon: Flame },
{ key: 'star', label: '精华', Icon: Star },
{ key: 'folder', label: '资源', Icon: Folder },
{ key: 'wrench', label: '工具', Icon: Wrench },
{ key: 'cpu', label: '硬件', Icon: Cpu },
];
const ICON_MAP = Object.fromEntries(
BOARD_ICON_OPTIONS.map(o => [o.key, o.Icon]),
) as Record<string, LucideIcon>;
const DEFAULT_ICONS: LucideIcon[] = [
Code2, Coffee, HelpCircle, MessageSquare,
Lightbulb, BookOpen, Gamepad2, Palette,
];
export type BoardVisual = Pick<Board, 'id' | 'icon' | 'color_index'>;
/** 按板块 id / color_index 取稳定色槽索引 */
export function getBoardThemeIndex(board: BoardVisual | number): number {
if (typeof board === 'object' && board.color_index != null && board.color_index >= 0) {
return board.color_index % BOARD_PALETTE_SIZE;
}
const id = typeof board === 'number' ? board : board.id;
return ((id % BOARD_PALETTE_SIZE) + BOARD_PALETTE_SIZE) % BOARD_PALETTE_SIZE;
}
/** 解析板块图标:优先使用后台配置,否则按 id 回退 */
export function getBoardIcon(board: BoardVisual | number): LucideIcon {
if (typeof board === 'object' && board.icon && ICON_MAP[board.icon]) {
return ICON_MAP[board.icon];
}
return DEFAULT_ICONS[getBoardThemeIndex(board)];
}
export function getBoardIconOption(key: string | undefined) {
if (!key) return undefined;
return BOARD_ICON_OPTIONS.find(o => o.key === key);
}

View File

@@ -114,7 +114,8 @@ func calcTotalPages(total int64, size int) int {
func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), sortOrder)
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -125,7 +126,8 @@ func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
func (h *Handlers) AdminAPIUpdateBoard(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), sortOrder); err != nil {
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

View File

@@ -64,13 +64,15 @@ func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
board, err := h.Board.Create(req.Name, req.Description, req.SortOrder)
board, err := h.Board.Create(req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -84,13 +86,15 @@ func (h *Handlers) APIAdminUpdateBoard(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Board.Update(uint(id), req.Name, req.Description, req.SortOrder); err != nil {
if err := h.Board.Update(uint(id), req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

View File

@@ -34,6 +34,8 @@ type Board struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
Icon string `gorm:"size:64;default:''" json:"icon"`
ColorIndex int `gorm:"default:-1" json:"color_index"` // -1 表示按 id 自动取色
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -45,14 +45,24 @@ func (s *BoardService) GetByID(id uint) (*model.Board, error) {
return &board, nil
}
func (s *BoardService) Create(name, desc string, sortOrder int) (*model.Board, error) {
board := &model.Board{Name: name, Description: desc, SortOrder: sortOrder}
func (s *BoardService) Create(name, desc, icon string, colorIndex, sortOrder int) (*model.Board, error) {
board := &model.Board{
Name: name,
Description: desc,
Icon: NormalizeBoardIcon(icon),
ColorIndex: NormalizeBoardColorIndex(colorIndex),
SortOrder: sortOrder,
}
return board, model.DB.Create(board).Error
}
func (s *BoardService) Update(id uint, name, desc string, sortOrder int) error {
func (s *BoardService) Update(id uint, name, desc, icon string, colorIndex, sortOrder int) error {
return model.DB.Model(&model.Board{}).Where("id = ?", id).Updates(map[string]interface{}{
"name": name, "description": desc, "sort_order": sortOrder,
"name": name,
"description": desc,
"icon": NormalizeBoardIcon(icon),
"color_index": NormalizeBoardColorIndex(colorIndex),
"sort_order": sortOrder,
}).Error
}

36
service/board_icon.go Normal file
View File

@@ -0,0 +1,36 @@
package service
import "strings"
// AllowedBoardIcons 与前端 BOARD_ICON_OPTIONS 的 key 保持一致
var AllowedBoardIcons = map[string]struct{}{
"code-2": {}, "coffee": {}, "help-circle": {}, "message-square": {},
"lightbulb": {}, "book-open": {}, "gamepad-2": {}, "palette": {},
"music": {}, "camera": {}, "heart": {}, "zap": {},
"globe": {}, "users": {}, "briefcase": {}, "graduation-cap": {},
"shopping-bag": {}, "map-pin": {}, "megaphone": {}, "flame": {},
"star": {}, "folder": {}, "wrench": {}, "cpu": {},
}
// NormalizeBoardIcon 校验并规范化板块图标 key非法或空则返回空串
func NormalizeBoardIcon(icon string) string {
icon = strings.TrimSpace(strings.ToLower(icon))
if icon == "" {
return ""
}
if _, ok := AllowedBoardIcons[icon]; !ok {
return ""
}
return icon
}
// NormalizeBoardColorIndex -1 表示自动07 为有效色槽
func NormalizeBoardColorIndex(colorIndex int) int {
if colorIndex < 0 {
return -1
}
if colorIndex > 7 {
return colorIndex % 8
}
return colorIndex
}