支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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};
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
1
embed_static/static/spa/assets/HomePage-B75SKkZF.js
Normal file
1
embed_static/static/spa/assets/HomePage-B75SKkZF.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +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};
|
||||
1
embed_static/static/spa/assets/PinnedIcon-Dc0ZpCCq.js
Normal file
1
embed_static/static/spa/assets/PinnedIcon-Dc0ZpCCq.js
Normal 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};
|
||||
@@ -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
14
embed_static/static/spa/assets/PostDetailPage-Ca0chGU-.js
Normal file
14
embed_static/static/spa/assets/PostDetailPage-Ca0chGU-.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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`,
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
1
embed_static/static/spa/assets/dialog-DXwuwWJi.js
Normal file
1
embed_static/static/spa/assets/dialog-DXwuwWJi.js
Normal 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
2
embed_static/static/spa/assets/index-Cp7Ff9NP.js
Normal file
2
embed_static/static/spa/assets/index-Cp7Ff9NP.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/index-xhm8HZjc.css
Normal file
1
embed_static/static/spa/assets/index-xhm8HZjc.css
Normal file
File diff suppressed because one or more lines are too long
@@ -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
68
embed_static/static/spa/assets/react-vendor-BdyZmpPe.js
vendored
Normal file
68
embed_static/static/spa/assets/react-vendor-BdyZmpPe.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
456
embed_static/static/spa/assets/ui-vendor-B26O8baZ.js
Normal file
456
embed_static/static/spa/assets/ui-vendor-B26O8baZ.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -27,10 +27,10 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Board {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color_index?: number;
|
||||
sort_order: number;
|
||||
post_count?: number;
|
||||
}
|
||||
|
||||
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
93
frontend/src/components/BoardAppearancePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/BoardBadge.tsx
Normal file
18
frontend/src/components/BoardBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
14
frontend/src/components/BoardIconDisplay.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
30
frontend/src/components/PostListSkeleton.tsx
Normal file
30
frontend/src/components/PostListSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
7
frontend/src/components/ui/skeleton.tsx
Normal file
7
frontend/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal file
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
frontend/src/utils/boardTheme.ts
Normal file
71
frontend/src/utils/boardTheme.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
36
service/board_icon.go
Normal 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 表示自动;0–7 为有效色槽
|
||||
func NormalizeBoardColorIndex(colorIndex int) int {
|
||||
if colorIndex < 0 {
|
||||
return -1
|
||||
}
|
||||
if colorIndex > 7 {
|
||||
return colorIndex % 8
|
||||
}
|
||||
return colorIndex
|
||||
}
|
||||
Reference in New Issue
Block a user