完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
119
README.md
119
README.md
@@ -1,4 +1,4 @@
|
||||
<div align="center">
|
||||
|
||||
|
||||
# 姜十三论坛 Jiang13 Forum
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite,开箱即用。
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
[](LICENSE)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](#)
|
||||
|
||||
[License: MIT](LICENSE)
|
||||
[Go](go.mod)
|
||||
[React](frontend/package.json)
|
||||
[SQLite](#)
|
||||
|
||||
[快速开始](#-快速开始) ·
|
||||
[界面预览](#-界面预览) ·
|
||||
@@ -19,18 +20,20 @@
|
||||
[路线图](ROADMAP.md) ·
|
||||
[参与贡献](CONTRIBUTING.md)
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
|
||||
|
||||
<sub>浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表</sub>
|
||||
|
||||
<br>
|
||||
|
||||
浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表
|
||||
|
||||
|
||||
|
||||
|
||||
> **开发状态:** 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎参与共建。
|
||||
> 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -38,37 +41,15 @@
|
||||
|
||||
> 论坛用户第一眼看到的是界面。姜十三论坛采用清新绿色主题、高密度信息布局,兼顾桌面与移动端体验。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||
<br><b>浅色主题</b><br>
|
||||
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||
<br><b>暗色主题</b><br>
|
||||
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
|
||||
<br><b>帖子详情</b><br>
|
||||
<sub>TipTap 富文本渲染 · 标签展示 · 点赞收藏互动</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
|
||||
<br><b>移动端适配</b><br>
|
||||
<sub>板块快捷筛选 · Feed 排序 · 触控友好列表</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/compose.png" alt="发帖页" width="360">
|
||||
<br><b>发帖</b> — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片
|
||||
</p>
|
||||
| | |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| **浅色主题** 左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线 | **暗色主题** 一键切换 · 护眼阅读 · 全局色彩自适应 |
|
||||
| **帖子详情** TipTap 富文本渲染 · 标签展示 · 点赞收藏互动 | **移动端适配** 板块快捷筛选 · Feed 排序 · 触控友好列表 |
|
||||
|
||||
|
||||
|
||||
**发帖** — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片
|
||||
|
||||
---
|
||||
|
||||
@@ -76,14 +57,16 @@
|
||||
|
||||
### 界面与交互
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
||||
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
||||
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
|
||||
|
||||
| 特性 | 说明 |
|
||||
| --------- | -------------------------------------------- |
|
||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
||||
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
||||
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
|
||||
|
||||
|
||||
### 社区功能
|
||||
|
||||
@@ -157,22 +140,26 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
|
||||
### 启动参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--data` | `./data` | 数据目录(SQLite、上传、日志) |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| -------------- | -------- | ------------------------------------ |
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--data` | `./data` | 数据目录(SQLite、上传、日志) |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
|
||||
| 层级 | 技术 |
|
||||
| ------ | -------------------------------------------------------------- |
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **认证** | bcrypt · JWT Cookie |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **认证** | bcrypt · JWT Cookie |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -237,12 +224,14 @@ data/
|
||||
|
||||
项目**积极开发中**,作为论坛产品功能尚未完善,欢迎参与共建。
|
||||
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||
| ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 |
|
||||
|
||||
| 类型 | 示例 |
|
||||
| ------ | ---------------------------------- |
|
||||
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||
| ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 |
|
||||
| ✅ 管理后台 | React SPA:仪表盘、帖子置顶、用户禁言、论坛参数与敏感词配置 |
|
||||
| 📋 计划中 | 通知动态优化、邮件提醒 |
|
||||
| 📋 计划中 | 通知动态优化、邮件提醒 |
|
||||
|
||||
|
||||
完整列表见 **[路线图 ROADMAP.md](ROADMAP.md)**。发现问题请提交 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues),认领任务请参考 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
@@ -256,4 +245,4 @@ data/
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||
@@ -1 +1 @@
|
||||
import{e as A,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{e as D,S as C,B as d,a as m,n as c}from"./index-5C0q-ZMs.js";import{B as N}from"./badge-DB-zL5ml.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-BoQ7HSUR.js";import"./ui-vendor-BtpOfToU.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-1fwFMt02.js";import{i as D,S as C,B as d,a as m,n as c}from"./index-kfMSnNdB.js";import{B as N}from"./badge-C0Go598m.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-_wPjKojj.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};
|
||||
@@ -1 +1 @@
|
||||
import{e as h,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{e as o,a as x,S as j}from"./index-5C0q-ZMs.js";import{B as u}from"./badge-DB-zL5ml.js";import"./ui-vendor-BtpOfToU.js";function f(){const l=h(),{ready:t}=o(),[e,d]=n.useState(null),[r,c]=n.useState(!0);if(n.useEffect(()=>{t&&x.adminDashboard().then(d).finally(()=>c(!1))},[t]),!t||r)return s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(j,{size:"lg"})});if(!e)return null;const m=[{label:"注册用户",value:e.users,cls:"admin-stat-users"},{label:"帖子总数",value:e.posts,cls:"admin-stat-posts"},{label:"板块数量",value:e.boards,cls:"admin-stat-boards"},{label:"评论总数",value:e.comments,cls:"admin-stat-comments"},{label:"当前在线",value:e.online,cls:"admin-stat-online"}];return s.jsxs("div",{className:"admin-page",children:[s.jsxs("div",{className:"admin-page-head",children:[s.jsx("h1",{children:"仪表盘"}),s.jsx("p",{children:"论坛运行概览与最新帖子"})]}),s.jsx("div",{className:"admin-stat-grid",children:m.map(a=>s.jsxs("div",{className:`admin-stat-card ${a.cls}`,children:[s.jsx("div",{className:"admin-stat-value",children:a.value}),s.jsx("div",{className:"admin-stat-label",children:a.label})]},a.label))}),s.jsxs("div",{className:"admin-card",children:[s.jsxs("div",{className:"admin-card-head",children:[s.jsx("span",{children:"最新帖子"}),s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l("/admin/posts"),children:"查看全部 →"})]}),s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"ID"}),s.jsx("th",{children:"标题"}),s.jsx("th",{children:"作者"}),s.jsx("th",{children:"置顶"}),s.jsx("th",{children:"时间"})]})}),s.jsx("tbody",{children:e.recent_posts.map(a=>{var i;return s.jsxs("tr",{children:[s.jsx("td",{children:a.id}),s.jsx("td",{children:s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l(`/post/${a.id}`),children:a.title})}),s.jsx("td",{children:((i=a.user)==null?void 0:i.nickname)??"—"}),s.jsx("td",{children:a.pinned?s.jsx(u,{variant:"orange",children:"是"}):"—"}),s.jsx("td",{children:new Date(a.created_at).toLocaleString("zh-CN")})]},a.id)})})]}),e.recent_posts.length===0&&s.jsx("div",{className:"admin-empty",children:"暂无帖子"})]})]})}export{f as default};
|
||||
import{e as h,r as n,j as s}from"./react-vendor-1fwFMt02.js";import{i as o,a as x,S as j}from"./index-kfMSnNdB.js";import{B as u}from"./badge-C0Go598m.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};
|
||||
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-CFzzFOsg.js";import{e as v,S as y,B as l,a as j,n as r}from"./index-5C0q-ZMs.js";import{B as h}from"./badge-DB-zL5ml.js";import"./ui-vendor-BtpOfToU.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-1fwFMt02.js";import{i as v,S as y,B as l,a as j,n as r}from"./index-kfMSnNdB.js";import{B as h}from"./badge-C0Go598m.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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
344
embed_static/static/spa/assets/ComposePage-DOjc4_kJ.js
Normal file
344
embed_static/static/spa/assets/ComposePage-DOjc4_kJ.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 j,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{u as f,a as g,n as N,S as v,B as m}from"./index-5C0q-ZMs.js";import{f as y}from"./content-BrFHUpp8.js";import{A as k}from"./ui-vendor-BtpOfToU.js";function S(){const a=j(),{user:t,loading:i}=f(),[r,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!i){if(!t){a("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>N.error(e.message)).finally(()=>x(!1))}},[t,i,a]),i||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(v,{size:"lg"})}):t?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(m,{variant:"ghost",className:"mb-3",onClick:()=>a("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",r.length," 篇收藏帖子"]}),r.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(m,{onClick:()=>a("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:r.map(e=>{var o,l,c,d,p;return s.jsx("div",{className:"post-row",onClick:()=>a(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((p=(d=e.post)==null?void 0:d.user)==null?void 0:p.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{S as default};
|
||||
import{e as j,r as n,j as s}from"./react-vendor-1fwFMt02.js";import{u as f,a as g,n as N,S as v,B as m}from"./index-kfMSnNdB.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([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!i){if(!t){a("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>N.error(e.message)).finally(()=>x(!1))}},[t,i,a]),i||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(v,{size:"lg"})}):t?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(m,{variant:"ghost",className:"mb-3",onClick:()=>a("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",r.length," 篇收藏帖子"]}),r.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(m,{onClick:()=>a("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:r.map(e=>{var o,l,c,d,p;return s.jsx("div",{className:"post-row",onClick:()=>a(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((p=(d=e.post)==null?void 0:d.user)==null?void 0:p.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{S as default};
|
||||
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/HomePage-Bk9P_MHc.js
Normal file
1
embed_static/static/spa/assets/HomePage-Bk9P_MHc.js
Normal file
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-CFzzFOsg.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,f as i,o as N,s as m}from"./form-OSLy-cSS.js";import{u as v,B as L,a as S,n as d}from"./index-5C0q-ZMs.js";import{I as u}from"./input-CE0PJTgJ.js";import"./ui-vendor-BtpOfToU.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-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-PYX6hP9B.js";import{u as v,B as L,a as S,n as d}from"./index-kfMSnNdB.js";import{I as u}from"./input-BjHCK3cg.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};
|
||||
@@ -1 +1 @@
|
||||
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as i}from"./index-5C0q-ZMs.js";import{N as s}from"./ui-vendor-BtpOfToU.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};
|
||||
import{j as n}from"./react-vendor-1fwFMt02.js";import{f as i}from"./index-kfMSnNdB.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};
|
||||
14
embed_static/static/spa/assets/PostDetailPage-BXxbrPMM.js
Normal file
14
embed_static/static/spa/assets/PostDetailPage-BXxbrPMM.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
95
embed_static/static/spa/assets/ProfilePage-SfO7sDgP.js
Normal file
95
embed_static/static/spa/assets/ProfilePage-SfO7sDgP.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{e as g,r as w,j as s,L as F}from"./react-vendor-CFzzFOsg.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-OSLy-cSS.js";import{d as S,u as v,B as C,a as L,n as x}from"./index-5C0q-ZMs.js";import{I as m}from"./input-CE0PJTgJ.js";import"./ui-vendor-BtpOfToU.js";const _=r=>N({username:c().min(1,"请输入用户名"),nickname:c().optional(),password:c().min(r,`密码至少 ${r} 位`)});function $(){const{limits:r}=S(),p=g(),{refresh:j}=v(),[h,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 j(),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:h,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 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-PYX6hP9B.js";import{h as S,u as v,B as C,a as L,n as x}from"./index-kfMSnNdB.js";import{I as m}from"./input-BjHCK3cg.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};
|
||||
@@ -1 +1 @@
|
||||
import{r as l,j as s}from"./react-vendor-CFzzFOsg.js";import{ad as g,ae as x,af as N,ag as i,ah as d,ai as r,aj as n,ak as m,al as c}from"./ui-vendor-BtpOfToU.js";import{c as o,g as f}from"./index-5C0q-ZMs.js";const C=g,T=x,y=N,p=l.forwardRef(({className:a,...e},t)=>s.jsx(c,{className:o("fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",a),...e,ref:t}));p.displayName=c.displayName;const u=l.forwardRef(({className:a,...e},t)=>s.jsxs(y,{children:[s.jsx(p,{}),s.jsx(i,{ref:t,className:o("fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",a),...e})]}));u.displayName=i.displayName;const A=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col space-y-2 text-center sm:text-left",a),...e});A.displayName="AlertDialogHeader";const D=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",a),...e});D.displayName="AlertDialogFooter";const j=l.forwardRef(({className:a,...e},t)=>s.jsx(d,{ref:t,className:o("text-lg font-semibold",a),...e}));j.displayName=d.displayName;const w=l.forwardRef(({className:a,...e},t)=>s.jsx(r,{ref:t,className:o("text-sm text-muted-foreground",a),...e}));w.displayName=r.displayName;const b=l.forwardRef(({className:a,...e},t)=>s.jsx(m,{ref:t,className:o(f(),a),...e}));b.displayName=m.displayName;const R=l.forwardRef(({className:a,...e},t)=>s.jsx(n,{ref:t,className:o(f({variant:"outline"}),"mt-2 sm:mt-0",a),...e}));R.displayName=n.displayName;export{C as A,T as a,u as b,A as c,j as d,w as e,D as f,R as g,b as h};
|
||||
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-kfMSnNdB.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};
|
||||
@@ -1 +1 @@
|
||||
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as a,f as o}from"./index-5C0q-ZMs.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-1fwFMt02.js";import{f as a,j as o}from"./index-kfMSnNdB.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
embed_static/static/spa/assets/dialog-DJohwkVv.js
Normal file
1
embed_static/static/spa/assets/dialog-DJohwkVv.js
Normal file
@@ -0,0 +1 @@
|
||||
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-kfMSnNdB.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 +0,0 @@
|
||||
const c="j13-feed-cache:";function r(t,e,s){return`${c}${t}:${e}:${s}`}function a(t,e,s){try{const n=sessionStorage.getItem(r(t,e,s));return n?JSON.parse(n):null}catch{return null}}function o(t,e,s,n){try{sessionStorage.setItem(r(t,e,s),JSON.stringify(n))}catch{}}function i(){try{for(let t=sessionStorage.length-1;t>=0;t--){const e=sessionStorage.key(t);e!=null&&e.startsWith(c)&&sessionStorage.removeItem(e)}}catch{}}export{i as c,a as g,o as s};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/index-BsZMEGDY.css
Normal file
1
embed_static/static/spa/assets/index-BsZMEGDY.css
Normal file
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-kfMSnNdB.js
Normal file
2
embed_static/static/spa/assets/index-kfMSnNdB.js
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-CFzzFOsg.js";import{c as n}from"./index-5C0q-ZMs.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-1fwFMt02.js";import{f as n}from"./index-kfMSnNdB.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
@@ -1 +1 @@
|
||||
import{r as s,j as a}from"./react-vendor-CFzzFOsg.js";import{c as t}from"./index-5C0q-ZMs.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-1fwFMt02.js";import{f as t}from"./index-kfMSnNdB.js";const i=s.forwardRef(({className:e,...r},o)=>a.jsx("textarea",{className:t("flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",e),ref:o,...r}));i.displayName="Textarea";export{i as T};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -27,10 +27,10 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-5C0q-ZMs.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CFzzFOsg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BtpOfToU.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG22moG5.css">
|
||||
<script type="module" crossorigin src="/assets/index-kfMSnNdB.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-BsZMEGDY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -31,20 +31,24 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
@@ -831,6 +835,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2477,6 +2487,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -3179,6 +3196,18 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3255,6 +3284,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -3662,6 +3697,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
@@ -4133,6 +4181,19 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -32,20 +32,24 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
@@ -9,14 +10,28 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
prefixMarkdownLines,
|
||||
cycleMarkdownHeading,
|
||||
insertMarkdownMembersOnly,
|
||||
insertMarkdownLink,
|
||||
} from '../utils/markdownFormat';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
@@ -30,14 +45,21 @@ interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type EditorMode = 'rich' | 'markdown';
|
||||
type LinkTarget = 'rich' | 'markdown';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
@@ -48,7 +70,7 @@ function isEditorEmpty(editor: Editor): boolean {
|
||||
return editor.state.doc.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
|
||||
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||||
function cycleHeading(editor: Editor) {
|
||||
for (let level = 2; level <= 6; level += 1) {
|
||||
if (editor.isActive('heading', { level })) {
|
||||
@@ -63,6 +85,51 @@ function cycleHeading(editor: Editor) {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}
|
||||
|
||||
/** 触发图片文件选择并上传 */
|
||||
async function uploadPostImageFile(): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
resolve(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** 渲染工具栏按钮列表 */
|
||||
function renderToolButtons(tools: ToolBtn[]) {
|
||||
return tools.map((t, i) => (
|
||||
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
|
||||
{t.className === 'article-tool-btn--members' ? (
|
||||
<span className="article-editor-tools-sep" aria-hidden="true" />
|
||||
) : null}
|
||||
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
||||
ref,
|
||||
@@ -70,11 +137,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
const isInternalUpdate = useRef(false);
|
||||
const lastValueRef = useRef(value);
|
||||
const [, setEditorTick] = useState(0);
|
||||
const [mode, setMode] = useState<EditorMode>('rich');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState('');
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [linkDialogUrl, setLinkDialogUrl] = useState('');
|
||||
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
@@ -83,8 +157,17 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
return MEMBERS_ONLY_PLACEHOLDER;
|
||||
}
|
||||
return placeholder;
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
@@ -92,20 +175,41 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
|
||||
// 清空后折叠选区,避免空文档残留全选高亮
|
||||
if (isEditorEmpty(ed) && !ed.state.selection.empty) {
|
||||
ed.commands.setTextSelection(1);
|
||||
}
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
onTransaction: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
onSelectionUpdate: () => setEditorTick(t => t + 1),
|
||||
onTransaction: () => setEditorTick(t => t + 1),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'article-prosemirror post-detail-content',
|
||||
spellcheck: 'false',
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||||
if (!view.state.doc.textContent.trim()) {
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, 1),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!editor || mode !== 'rich') return;
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
@@ -114,46 +218,104 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
if (next === lastValueRef.current) return;
|
||||
lastValueRef.current = next;
|
||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||
}, [value, editor]);
|
||||
}, [value, editor, mode]);
|
||||
|
||||
// 全屏时锁定页面滚动,Esc 退出
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return undefined;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
|
||||
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
|
||||
focus: () => editor?.commands.focus(),
|
||||
}), [editor, value]);
|
||||
getHTML: () => {
|
||||
if (mode === 'markdown') {
|
||||
return sanitizeHtml(markdownToHtml(markdownSource));
|
||||
}
|
||||
return editor ? sanitizeHtml(editor.getHTML()) : value;
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (mode === 'markdown') {
|
||||
return markdownSource.trim().length === 0;
|
||||
}
|
||||
return editor ? isEditorEmpty(editor) : !value.trim();
|
||||
},
|
||||
focus: () => {
|
||||
if (mode === 'markdown') {
|
||||
markdownRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
editor?.commands.focus();
|
||||
},
|
||||
}), [editor, value, mode, markdownSource]);
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
const handleMarkdownChange = useCallback((next: string) => {
|
||||
setMarkdownSource(next);
|
||||
const html = sanitizeHtml(markdownToHtml(next));
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
}, [onChange]);
|
||||
|
||||
const openLinkDialog = useCallback((target: LinkTarget) => {
|
||||
if (target === 'rich') {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
setLinkDialogUrl(prev ?? '');
|
||||
} else {
|
||||
setLinkDialogUrl('');
|
||||
}
|
||||
setLinkTarget(target);
|
||||
setLinkDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (linkTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea || !url) return;
|
||||
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
const url = window.prompt('链接地址', prev ?? 'https://');
|
||||
if (url === null) return;
|
||||
if (!url.trim()) {
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}, [editor]);
|
||||
|
||||
const setImage = useCallback(() => {
|
||||
const setImage = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
const url = await uploadPostImageFile();
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('membersOnly')) {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapMembersOnly().run();
|
||||
@@ -162,113 +324,197 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
editor.chain().focus().insertMembersOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const buildTools = useCallback((): ToolBtn[] => {
|
||||
const switchToMarkdown = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const html = sanitizeHtml(editor.getHTML());
|
||||
lastValueRef.current = html;
|
||||
setMarkdownSource(htmlToMarkdown(html));
|
||||
setMode('markdown');
|
||||
}, [editor]);
|
||||
|
||||
const switchToRich = useCallback(() => {
|
||||
const html = sanitizeHtml(markdownToHtml(markdownSource));
|
||||
lastValueRef.current = html;
|
||||
isInternalUpdate.current = true;
|
||||
onChange(html);
|
||||
if (editor) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
}
|
||||
setMode('rich');
|
||||
}, [editor, markdownSource, onChange]);
|
||||
|
||||
const withMarkdown = useCallback((fn: (
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
) => void) => () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
fn(textarea, markdownSource, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const insertMarkdownImage = useCallback(async () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (!url) return;
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
{
|
||||
icon: <strong>H</strong>,
|
||||
title: '标题(H2,再次点击升级)',
|
||||
active: editor.isActive('heading'),
|
||||
action: () => cycleHeading(editor),
|
||||
},
|
||||
{
|
||||
icon: <Bold size={15} />,
|
||||
title: '加粗',
|
||||
active: editor.isActive('bold'),
|
||||
action: () => editor.chain().focus().toggleBold().run(),
|
||||
},
|
||||
{
|
||||
icon: <Italic size={15} />,
|
||||
title: '斜体',
|
||||
active: editor.isActive('italic'),
|
||||
action: () => editor.chain().focus().toggleItalic().run(),
|
||||
},
|
||||
{
|
||||
icon: <Strikethrough size={15} />,
|
||||
title: '删除线',
|
||||
active: editor.isActive('strike'),
|
||||
action: () => editor.chain().focus().toggleStrike().run(),
|
||||
},
|
||||
{
|
||||
icon: <Minus size={15} />,
|
||||
title: '分割线',
|
||||
action: () => editor.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
icon: <Quote size={15} />,
|
||||
title: '引用',
|
||||
active: editor.isActive('blockquote'),
|
||||
action: () => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
icon: <List size={15} />,
|
||||
title: '无序列表',
|
||||
active: editor.isActive('bulletList'),
|
||||
action: () => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
icon: <ListOrdered size={15} />,
|
||||
title: '有序列表',
|
||||
active: editor.isActive('orderedList'),
|
||||
action: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
icon: <Code size={15} />,
|
||||
title: '代码块',
|
||||
active: editor.isActive('codeBlock'),
|
||||
action: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
icon: <LinkIcon size={15} />,
|
||||
title: '链接',
|
||||
active: editor.isActive('link'),
|
||||
action: setLink,
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '上传图片',
|
||||
action: setImage,
|
||||
},
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
|
||||
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
|
||||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
|
||||
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
|
||||
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见(选中文字后点击可包裹)',
|
||||
title: '登录可见',
|
||||
hint: '独立输入区;Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
];
|
||||
}, [editor, setLink, setImage, wrapMembersOnly]);
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
|
||||
|
||||
const tools = buildTools();
|
||||
const words = editor ? countWords(editor.getText()) : 0;
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
||||
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
? countWords(markdownSource)
|
||||
: (editor ? countWords(editor.getText()) : 0);
|
||||
|
||||
return (
|
||||
<div className="article-editor">
|
||||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{tools.map((t, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
title={t.title}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
{renderToolButtons(tools)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-body">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
{mode === 'rich' ? (
|
||||
<div className="article-editor-pane article-editor-pane--rich">
|
||||
<div className="article-editor-scroll">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="article-editor-markdown">
|
||||
<div className="article-editor-pane article-editor-pane--source">
|
||||
<div className="article-editor-scroll">
|
||||
<textarea
|
||||
ref={markdownRef}
|
||||
className="article-editor-markdown-input"
|
||||
value={markdownSource}
|
||||
onChange={e => handleMarkdownChange(e.target.value)}
|
||||
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
|
||||
placeholder="在此编写 Markdown 源码…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="article-editor-markdown-preview">
|
||||
<div className="article-editor-markdown-preview-label">预览</div>
|
||||
<div
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<span>{words} 字</span>
|
||||
<span>富文本</span>
|
||||
<div className="article-editor-status-meta">
|
||||
<span>{words} 字</span>
|
||||
<span className="article-editor-status-sep">·</span>
|
||||
<span>
|
||||
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
|
||||
{' · Tab 缩进 / Shift+Tab 回退'}
|
||||
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status-actions">
|
||||
<Tooltip
|
||||
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
|
||||
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
|
||||
>
|
||||
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
|
||||
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={fullscreen ? '退出全屏' : '全屏编辑'}
|
||||
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="article-editor-view-btn"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setFullscreen(v => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArticleLinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
initialUrl={linkDialogUrl}
|
||||
onConfirm={applyLink}
|
||||
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
116
frontend/src/components/AvatarCropDialog.tsx
Normal file
116
frontend/src/components/AvatarCropDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Cropper, { type Area } from 'react-easy-crop';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { getCroppedAvatarFile } from '../utils/avatarCrop';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
fileName?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (file: File) => void;
|
||||
}
|
||||
|
||||
export default function AvatarCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
fileName,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
}, [open, imageSrc]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
|
||||
onConfirm(file);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
notify.error('裁剪失败,请重试');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="avatar-crop-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>
|
||||
拖动图片调整位置,滚轮或滑块缩放。GIF 裁剪后将变为静态 JPG。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="avatar-crop-stage">
|
||||
{imageSrc ? (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-crop-loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="avatar-crop-zoom">
|
||||
<span className="avatar-crop-zoom-label">缩放</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={e => setZoom(Number(e.target.value))}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={confirming} onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={confirming} disabled={!imageSrc} onClick={handleConfirm}>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -48,13 +48,12 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<div className="widget-online-list">
|
||||
{members.map(u => (
|
||||
<span key={u.id} title={u.nickname} style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)',
|
||||
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{u.nickname?.[0] || '?'}
|
||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" />
|
||||
: (u.nickname?.[0] || '?')}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Board } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -53,7 +54,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
|
||||
</nav>
|
||||
|
||||
@@ -66,7 +67,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
|
||||
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
|
||||
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
restoreScrollTop?: number | null;
|
||||
/** 递增时强制回到列表顶部(主动刷新导航) */
|
||||
resetScrollKey?: number;
|
||||
onScrollTopChange?: (top: number) => void;
|
||||
onScrollRestored?: () => void;
|
||||
}
|
||||
@@ -26,6 +28,7 @@ export default function VirtualPostList({
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
}: Props) {
|
||||
@@ -39,6 +42,17 @@ export default function VirtualPostList({
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
const el = parentRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = 0;
|
||||
virtualizer.scrollToOffset(0);
|
||||
}
|
||||
restoredRef.current = true;
|
||||
onScrollTopChange?.(0);
|
||||
}, [resetScrollKey, virtualizer, onScrollTopChange]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||
virtualizer.scrollToOffset(restoreScrollTop);
|
||||
|
||||
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal file
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialUrl?: string;
|
||||
onConfirm: (url: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** 文章编辑器链接输入弹窗 */
|
||||
export function ArticleLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialUrl = '',
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setUrl(initialUrl || 'https://');
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(url.trim());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-link-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>插入链接</DialogTitle>
|
||||
<DialogDescription>输入完整 URL,留空并确认可移除已有链接。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://"
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter className="article-link-dialog__footer">
|
||||
{onRemove && initialUrl ? (
|
||||
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||
移除链接
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,26 @@ import {
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole } from 'lucide-react';
|
||||
import { LockKeyhole, LogOut } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的登录可见节点深度 */
|
||||
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected }: NodeViewProps) {
|
||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
const handleExit = () => {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="members-only"
|
||||
@@ -19,6 +35,26 @@ function MembersOnlyView({ selected }: NodeViewProps) {
|
||||
<LockKeyhole size={12} />
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__exit-btn"
|
||||
title="Ctrl+Enter 退出到公开区域"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleExit}
|
||||
>
|
||||
<LogOut size={11} />
|
||||
退出
|
||||
</button>
|
||||
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退出</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
</NodeViewWrapper>
|
||||
@@ -30,6 +66,8 @@ declare module '@tiptap/core' {
|
||||
membersOnly: {
|
||||
insertMembersOnly: () => ReturnType;
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -54,15 +92,37 @@ export const MembersOnly = Node.create({
|
||||
return ReactNodeViewRenderer(MembersOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const parent = $from.parent;
|
||||
const atBlockEnd = $from.parentOffset === parent.content.size;
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('membersOnly')) return false;
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertMembersOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
|
||||
}],
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
@@ -79,6 +139,33 @@ export const MembersOnly = Node.create({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
exitMembersOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
unwrapMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal file
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
/** 收集选区覆盖的文本块 */
|
||||
function collectSelectedTextblocks(editor: Editor): { pos: number }[] {
|
||||
const { from, to } = editor.state.selection;
|
||||
const blocks: { pos: number }[] = [];
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.isTextblock) {
|
||||
blocks.push({ pos });
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 代码块多行选区:每行首插入缩进 */
|
||||
function indentCodeBlockSelection(editor: Editor): boolean {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const text = editor.state.doc.textBetween(from, to, '\n', '\n');
|
||||
const lineStarts = [from];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') lineStarts.push(from + i + 1);
|
||||
}
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
lineStarts.forEach(pos => {
|
||||
tr = tr.insertText(TAB_SPACES, pos + offset);
|
||||
offset += TAB_SPACES.length;
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 代码块多行选区:每行首移除最多 4 个空格 */
|
||||
function outdentCodeBlockSelection(editor: Editor): boolean {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const doc = editor.state.doc;
|
||||
const text = doc.textBetween(from, to, '\n', '\n');
|
||||
const lineStarts = [from];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') lineStarts.push(from + i + 1);
|
||||
}
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
let changed = false;
|
||||
lineStarts.forEach(pos => {
|
||||
const adjPos = pos + offset;
|
||||
const lineEnd = doc.textBetween(adjPos, to + offset).split('\n')[0] ?? '';
|
||||
const match = lineEnd.match(/^ {1,4}/);
|
||||
if (!match) return;
|
||||
tr = tr.delete(adjPos, adjPos + match[0].length);
|
||||
offset -= match[0].length;
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) return false;
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 多行选区:在每个文本块行首插入缩进 */
|
||||
function indentSelection(editor: Editor): boolean {
|
||||
const { empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return indentCodeBlockSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
|
||||
const blocks = collectSelectedTextblocks(editor);
|
||||
if (blocks.length === 0) return false;
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
blocks.forEach(({ pos }) => {
|
||||
const insertPos = pos + 1 + offset;
|
||||
tr = tr.insertText(TAB_SPACES, insertPos);
|
||||
offset += TAB_SPACES.length;
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 多行选区:移除每个文本块行首最多 4 个空格 */
|
||||
function outdentSelection(editor: Editor): boolean {
|
||||
const { empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return outdentCodeBlockSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().liftListItem('listItem').run();
|
||||
}
|
||||
|
||||
const blocks = collectSelectedTextblocks(editor);
|
||||
if (blocks.length === 0) return false;
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
blocks.forEach(({ pos }) => {
|
||||
const node = editor.state.doc.nodeAt(pos);
|
||||
if (!node?.isTextblock) return;
|
||||
const text = node.textContent;
|
||||
const match = text.match(/^ {1,4}/);
|
||||
if (!match) return;
|
||||
const removeLen = match[0].length;
|
||||
const from = pos + 1 + offset;
|
||||
tr = tr.delete(from, from + removeLen);
|
||||
offset -= removeLen;
|
||||
});
|
||||
|
||||
if (tr.steps.length === 0) return false;
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 在代码块或段落中移除光标前的 4 个空格缩进 */
|
||||
function outdentTextblock(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const { $from, empty } = state.selection;
|
||||
if (!empty) return false;
|
||||
if (!$from.parent.isTextblock) return false;
|
||||
|
||||
const lineStart = $from.start();
|
||||
const beforeCursor = state.doc.textBetween(lineStart, $from.pos);
|
||||
|
||||
if (beforeCursor.endsWith(TAB_SPACES)) {
|
||||
return editor.chain().focus().deleteRange({ from: $from.pos - TAB_SPACES.length, to: $from.pos }).run();
|
||||
}
|
||||
|
||||
const leading = beforeCursor.match(/^ {1,4}/);
|
||||
if (leading) {
|
||||
return editor.chain().focus().deleteRange({ from: lineStart, to: lineStart + leading[0].length }).run();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Tab 缩进:列表缩进/反缩进,多行选区整体缩进,其余插入 4 个空格 */
|
||||
export const TabIndent = Extension.create({
|
||||
name: 'tabIndent',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
if (!editor.state.selection.empty) {
|
||||
return indentSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return editor.chain().focus().insertContent(TAB_SPACES).run();
|
||||
}
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
return editor.chain().focus().insertContent(TAB_SPACES).run();
|
||||
},
|
||||
'Shift-Tab': ({ editor }) => {
|
||||
if (!editor.state.selection.empty) {
|
||||
return outdentSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return outdentTextblock(editor);
|
||||
}
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().liftListItem('listItem').run();
|
||||
}
|
||||
return outdentTextblock(editor);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
49
frontend/src/components/ui/Tooltip.tsx
Normal file
49
frontend/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cloneElement, isValidElement, type ReactElement } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type TooltipSide = 'top' | 'bottom';
|
||||
type TooltipAlign = 'start' | 'center' | 'end';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
hint?: string;
|
||||
side?: TooltipSide;
|
||||
align?: TooltipAlign;
|
||||
children: ReactElement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 即时显示的网页式气泡提示,替代原生 title */
|
||||
export function Tooltip({
|
||||
content,
|
||||
hint,
|
||||
side = 'bottom',
|
||||
align = 'center',
|
||||
children,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
if (!isValidElement(children)) return children;
|
||||
|
||||
const ariaLabel = hint ? `${content},${hint}` : content;
|
||||
const child = cloneElement(children, {
|
||||
'aria-label': ariaLabel,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
return (
|
||||
<span className={cn('ui-tooltip', className)}>
|
||||
{child}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
'ui-tooltip-bubble',
|
||||
`ui-tooltip-bubble--${side}`,
|
||||
`ui-tooltip-bubble--${align}`,
|
||||
hint ? 'ui-tooltip-bubble--rich' : 'ui-tooltip-bubble--compact',
|
||||
)}
|
||||
>
|
||||
<span className="ui-tooltip-bubble__title">{content}</span>
|
||||
{hint ? <span className="ui-tooltip-bubble__hint">{hint}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export default function MainLayout() {
|
||||
@@ -113,7 +114,7 @@ export default function MainLayout() {
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => nav('/')}>
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
</button>
|
||||
@@ -214,13 +215,13 @@ export default function MainLayout() {
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
|
||||
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
|
||||
>全部</span>
|
||||
{boards.map(b => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
|
||||
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
|
||||
>{b.name}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
@@ -7,10 +7,18 @@ import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
|
||||
import {
|
||||
getFeedCache,
|
||||
setFeedCache,
|
||||
clearAllFeedCache,
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
@@ -24,7 +32,24 @@ export default function HomePage() {
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
pageWrapRef.current?.scrollTo(0);
|
||||
}, []);
|
||||
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
skipCacheSaveRef.current = true;
|
||||
clearAllFeedCache();
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
setLoading(true);
|
||||
@@ -37,7 +62,6 @@ export default function HomePage() {
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
// 切换筛选时保留旧列表,避免中间区域瞬间空白
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
@@ -80,6 +104,12 @@ export default function HomePage() {
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
setPosts(cached.posts);
|
||||
@@ -95,11 +125,11 @@ export default function HomePage() {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, sort, load, revalidate]);
|
||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (posts.length === 0) return;
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
@@ -110,29 +140,42 @@ export default function HomePage() {
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) {
|
||||
skipCacheSaveRef.current = false;
|
||||
}
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => {
|
||||
beginFeedRefresh();
|
||||
};
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
}, [beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [boardId, keyword, sort, load]);
|
||||
}, [beginFeedRefresh, load]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) return;
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
nav(buildHomeUrl(boardId, next));
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-wrap" ref={pageWrapRef}>
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
@@ -153,6 +196,7 @@ export default function HomePage() {
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,6 +13,8 @@ import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
@@ -36,7 +38,14 @@ export default function ProfilePage() {
|
||||
const [nickLoading, setNickLoading] = useState(false);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
|
||||
const [cropFileName, setCropFileName] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
@@ -56,6 +65,30 @@ export default function ProfilePage() {
|
||||
}
|
||||
}, [authLoading, user, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
};
|
||||
}, [cropImageSrc]);
|
||||
|
||||
const closeCropDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCropOpen(false);
|
||||
setCropImageSrc(prev => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setCropFileName('');
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
@@ -90,26 +123,88 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
|
||||
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
|
||||
const clearPendingAvatar = () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(null);
|
||||
setAvatarPreview(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const openCropForFile = (file: File) => {
|
||||
const err = validateAvatarFile(file, limits.avatar_max_mb);
|
||||
if (err) {
|
||||
notify.error(err);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
setCropFileName(file.name);
|
||||
setCropImageSrc(URL.createObjectURL(file));
|
||||
setCropOpen(true);
|
||||
};
|
||||
|
||||
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const onCropConfirm = (file: File) => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const onSaveAvatar = async () => {
|
||||
if (!pendingAvatar) return;
|
||||
setAvatarLoading(true);
|
||||
try {
|
||||
await api.uploadAvatar(file);
|
||||
await api.uploadAvatar(pendingAvatar);
|
||||
await refresh();
|
||||
clearPendingAvatar();
|
||||
notify.success('头像已更新');
|
||||
} catch (err: unknown) {
|
||||
notify.error(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current += 1;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current <= 0) {
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
if (avatarLoading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const displayAvatar = avatarPreview ?? user.avatar;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
|
||||
@@ -117,18 +212,82 @@ export default function ProfilePage() {
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title mb-4">个人中心</h1>
|
||||
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-lg">
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
<div
|
||||
className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="profile-drop-overlay">
|
||||
<Upload size={28} strokeWidth={1.5} />
|
||||
<span>松开以上传头像</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={AVATAR_ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={onAvatarSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-avatar-btn"
|
||||
title="点击或拖拽图片到此处更换头像"
|
||||
disabled={avatarLoading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
|
||||
{displayAvatar
|
||||
? <img src={displayAvatar} alt="" />
|
||||
: user.nickname[0]}
|
||||
<span className="profile-avatar-overlay">
|
||||
{avatarLoading
|
||||
? <Spinner size="sm" className="text-white" />
|
||||
: <Camera size={22} strokeWidth={1.75} />}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="profile-header-info">
|
||||
<div className="profile-header-main">
|
||||
<h1 className="profile-display-name">{user.nickname}</h1>
|
||||
<div className="profile-username">@{user.username}</div>
|
||||
<p className="profile-avatar-tip">点击头像选择图片,或拖拽到此处</p>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
</div>
|
||||
{pendingAvatar && (
|
||||
<div className="profile-avatar-actions">
|
||||
<span className="profile-avatar-hint">已裁剪,待保存</span>
|
||||
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
|
||||
保存头像
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={avatarLoading}
|
||||
onClick={clearPendingAvatar}
|
||||
aria-label="取消"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarCropDialog
|
||||
open={cropOpen}
|
||||
imageSrc={cropImageSrc}
|
||||
fileName={cropFileName}
|
||||
onOpenChange={closeCropDialog}
|
||||
onConfirm={onCropConfirm}
|
||||
/>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="section-card admin-entry-card">
|
||||
<div className="section-card-title">管理员入口</div>
|
||||
@@ -151,7 +310,7 @@ export default function ProfilePage() {
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">基本资料</div>
|
||||
<Form {...nickForm}>
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
@@ -171,32 +330,20 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||
<div className="profile-form-footer">
|
||||
<span className="profile-form-hint">
|
||||
支持 JPG、PNG、GIF、WebP,头像不超过 {limits.avatar_max_mb}MB
|
||||
</span>
|
||||
<Button type="submit" loading={nickLoading}>保存</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">头像</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
|
||||
支持 JPG、PNG、GIF、WebP,不超过 2MB
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onAvatarChange}
|
||||
/>
|
||||
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
|
||||
选择图片上传
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">修改密码</div>
|
||||
<Form {...pwdForm}>
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
|
||||
<FormField
|
||||
control={pwdForm.control}
|
||||
name="old_password"
|
||||
@@ -236,9 +383,11 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
<div className="profile-form-footer profile-form-footer--end">
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
--j13-aside-w: 280px;
|
||||
--j13-header-h: 56px;
|
||||
--j13-max-w: 1400px;
|
||||
--j13-article-read-w: 820px;
|
||||
/* 层次:浅灰画布 → 工作区底 → 白色/淡色区块 */
|
||||
--j13-bg-page: #f5f7fa;
|
||||
--j13-bg-workspace: #f8fafc;
|
||||
@@ -354,10 +355,16 @@ a:hover { text-decoration: underline; }
|
||||
transition: box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.header-user-btn:hover {
|
||||
.header-user-btn:hover,
|
||||
.header-user-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--j13-green-bg), 0 0 0 3px var(--j13-green);
|
||||
}
|
||||
|
||||
/* 鼠标点击打开/关闭下拉后 focus 会回到按钮,隐藏默认黑色 outline */
|
||||
.header-user-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-user-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1585,7 +1592,16 @@ a:hover { text-decoration: underline; }
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.post-detail-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.post-detail-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.post-detail-content li {
|
||||
display: list-item;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1800,18 +1816,63 @@ a:hover { text-decoration: underline; }
|
||||
background: linear-gradient(90deg, var(--j13-bg-block-muted) 0%, rgba(255, 255, 255, 0.08) 100%);
|
||||
}
|
||||
|
||||
.article-editor-tools-members {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.article-editor-tools-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--j13-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-tools .article-tool-btn--members {
|
||||
margin-left: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--j13-border-light);
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.article-editor-tools .article-tool-btn--members:hover {
|
||||
.article-editor-tools .article-tool-btn--members:hover,
|
||||
.article-editor-tools .article-tool-btn--members.active {
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green-hover);
|
||||
}
|
||||
|
||||
.post-members-only__exit-btn,
|
||||
.post-members-only__unwrap-btn {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(24, 160, 88, 0.16);
|
||||
color: var(--j13-green);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.post-members-only__unwrap-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-members-only__exit-btn:hover {
|
||||
background: rgba(24, 160, 88, 0.28);
|
||||
color: var(--j13-green-hover);
|
||||
}
|
||||
|
||||
.post-members-only__unwrap-btn:hover {
|
||||
background: var(--j13-bg-block-muted);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.post-detail-content strong,
|
||||
.post-detail-content b {
|
||||
font-weight: 700;
|
||||
@@ -2465,18 +2526,255 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.widget-item:last-child { border-bottom: none; }
|
||||
.widget-item:hover { color: var(--j13-green); }
|
||||
|
||||
.widget-online-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.widget-online-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-online-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
|
||||
|
||||
.profile-header-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--j13-bg-block);
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--j13-shadow-soft);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.profile-header-card--dragover {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 50%, var(--j13-border-light));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 15%, transparent);
|
||||
}
|
||||
|
||||
.profile-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--j13-green) 12%, var(--j13-bg-block));
|
||||
color: var(--j13-green);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-avatar-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-avatar-lg {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
position: relative;
|
||||
width: 88px; height: 88px; border-radius: 50%;
|
||||
background: var(--j13-green); color: #fff;
|
||||
font-size: 28px; font-weight: 600;
|
||||
font-size: 32px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.profile-avatar-lg--pending {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 40%, transparent);
|
||||
}
|
||||
|
||||
.profile-avatar-lg img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.profile-avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:hover .profile-avatar-overlay,
|
||||
.profile-avatar-btn:focus-visible .profile-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:disabled .profile-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-header-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-display-name {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.profile-avatar-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-avatar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-hint {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.profile-form-footer--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.profile-form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.profile-header-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-header-info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-form-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.profile-form-hint {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 头像裁剪弹窗 */
|
||||
.avatar-crop-dialog {
|
||||
max-width: 420px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.avatar-crop-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.avatar-crop-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom input[type='range'] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
accent-color: var(--j13-green);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-entry-card {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 25%, var(--j13-border));
|
||||
background: var(--j13-bg-block-accent);
|
||||
@@ -2797,7 +3095,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 -32px;
|
||||
padding: 10px 32px;
|
||||
@@ -2808,6 +3105,114 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
/* 即时气泡提示(替代原生 title) */
|
||||
.ui-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: max-content;
|
||||
background: var(--j13-bg-surface);
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--j13-shadow-card);
|
||||
transition: opacity 0.1s ease, transform 0.1s ease, visibility 0.1s;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--compact {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--rich {
|
||||
min-width: 148px;
|
||||
max-width: 220px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble__title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--compact .ui-tooltip-bubble__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble__hint {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--bottom {
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--top {
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--center.ui-tooltip-bubble--bottom {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--center.ui-tooltip-bubble--top {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip-bubble--start.ui-tooltip-bubble--top {
|
||||
left: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip-bubble--end.ui-tooltip-bubble--top {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--center.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--center.ui-tooltip-bubble--bottom {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--center.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--center.ui-tooltip-bubble--top {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--start.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--end.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--start.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--end.ui-tooltip-bubble--top {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.article-editor-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2852,6 +3257,53 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.article-editor-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--j13-bg-block);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.article-editor-pane--source {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* 编辑器选区:淡色高亮,避免浏览器默认深蓝 */
|
||||
.article-editor-scroll ::selection,
|
||||
.article-editor-markdown-input::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 24%, #d6e8f0);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-prosemirror ::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 24%, #d6e8f0);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-prosemirror .ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--j13-green) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark .article-editor-scroll ::selection,
|
||||
.dark .article-editor-markdown-input::selection,
|
||||
.dark .article-prosemirror ::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 35%, #2a3f50);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-editor-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: var(--j13-bg-block);
|
||||
}
|
||||
|
||||
.article-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -2862,7 +3314,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.article-prosemirror {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
padding: 12px 0;
|
||||
padding: 14px 20px 20px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -2872,6 +3324,17 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 空文档占位段落不参与选区高亮,避免 Ctrl+A 后出现幽灵蓝条 */
|
||||
.article-prosemirror p.is-editor-empty::selection,
|
||||
.article-prosemirror p.is-editor-empty *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-prosemirror.ProseMirror-focused p.is-editor-empty::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-prosemirror a {
|
||||
@@ -2887,8 +3350,46 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.editor-members-only.post-members-only--visible {
|
||||
border: 1px solid rgba(24, 160, 88, 0.45);
|
||||
background: linear-gradient(135deg, rgba(24, 160, 88, 0.08), rgba(24, 160, 88, 0.02));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.editor-members-only .post-members-only__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px dashed rgba(24, 160, 88, 0.28);
|
||||
border-radius: 0;
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-members-only .post-members-only__body {
|
||||
padding: 14px 18px 18px;
|
||||
min-height: 96px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.dark .editor-members-only .post-members-only__body {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.post-members-only__shortcut-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.editor-members-only {
|
||||
margin: 16px 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.editor-members-only--selected {
|
||||
@@ -2899,6 +3400,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0 16px;
|
||||
border-top: 1px solid var(--j13-border-light);
|
||||
font-size: 12px;
|
||||
@@ -2906,6 +3408,176 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-status-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-editor-status-sep {
|
||||
color: var(--color-text-4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.article-editor-status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-view-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--j13-bg-block);
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-view-btn:hover {
|
||||
border-color: var(--j13-green);
|
||||
color: var(--j13-green);
|
||||
background: rgba(24, 160, 88, 0.06);
|
||||
}
|
||||
|
||||
.article-editor-view-btn.active {
|
||||
border-color: rgba(24, 160, 88, 0.45);
|
||||
color: var(--j13-green);
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
margin: 0 !important;
|
||||
padding: 0 32px 24px;
|
||||
background: var(--j13-bg-surface);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-bar {
|
||||
margin: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 全屏富文本:工具栏与编辑区同宽居中 */
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-bar {
|
||||
width: 100%;
|
||||
max-width: var(--j13-article-read-w);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 全屏富文本:编辑区居中,宽度对齐帖子正文阅读区 */
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-body {
|
||||
overflow-y: auto;
|
||||
padding: 20px 0 0;
|
||||
background: var(--j13-bg-workspace);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
||||
width: 100%;
|
||||
max-width: var(--j13-article-read-w);
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 176px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-scroll,
|
||||
.article-editor--fullscreen .article-editor-content .tiptap,
|
||||
.article-editor--fullscreen .article-prosemirror {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-markdown {
|
||||
min-height: calc(100vh - 176px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-markdown-input,
|
||||
.article-editor--fullscreen .article-editor-markdown-preview,
|
||||
.article-editor--fullscreen .article-editor-pane--source {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.article-editor-tools--markdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-editor-tools--markdown-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.article-link-dialog__footer {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-editor-markdown {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.article-editor-markdown-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 14px 16px 20px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
background: var(--j13-bg-surface);
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview-label {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview-body {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compose-canvas {
|
||||
padding: 0 16px;
|
||||
@@ -2916,11 +3588,44 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-bar {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.article-editor-markdown {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(240px, 1fr) minmax(240px, 1fr);
|
||||
}
|
||||
|
||||
.article-editor-pane--source {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.article-editor-bar {
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.article-editor-status {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.article-editor-status-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.compose-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
66
frontend/src/utils/avatarCrop.ts
Normal file
66
frontend/src/utils/avatarCrop.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Area } from 'react-easy-crop';
|
||||
|
||||
export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
/** 头像输出尺寸 */
|
||||
export const AVATAR_OUTPUT_SIZE = 512;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片加载失败'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验头像文件,返回错误信息或 null */
|
||||
export function validateAvatarFile(file: File, maxMb: number): string | null {
|
||||
if (!AVATAR_MIME_TYPES.includes(file.type)) {
|
||||
return '仅支持 JPG、PNG、GIF、WebP 格式';
|
||||
}
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return `头像不能超过 ${maxMb}MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将裁剪区域渲染为 JPEG 文件 */
|
||||
export async function getCroppedAvatarFile(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
originalName = 'avatar.jpg',
|
||||
): Promise<File> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = AVATAR_OUTPUT_SIZE;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('裁剪失败');
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
|
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
@@ -89,7 +92,6 @@ export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort)
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
@@ -110,3 +112,12 @@ export function clearAllFeedCache() {
|
||||
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
export const FEED_RESET_EVENT = 'feed-reset';
|
||||
|
||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||
}
|
||||
|
||||
229
frontend/src/utils/markdownContent.ts
Normal file
229
frontend/src/utils/markdownContent.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
const TURNDOWN_OPTIONS = {
|
||||
headingStyle: 'atx' as const,
|
||||
codeBlockStyle: 'fenced' as const,
|
||||
emDelimiter: '*',
|
||||
bulletListMarker: '-',
|
||||
};
|
||||
|
||||
/** 仅去除区块首尾空行,保留各行行首缩进 */
|
||||
function trimBlockBoundaryLines(content: string): string {
|
||||
return content.replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
/** 不换行空格还原为普通空格,便于 Markdown 源码编辑 */
|
||||
function nbspToSpaces(text: string): string {
|
||||
return text.replace(/\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
/** 将含 <br> 的段落拆成多个 <p>,避免聚合转换时丢失首行缩进 */
|
||||
function splitParagraphBreaks(html: string): string {
|
||||
if (!/<br\s*\/?>/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<div data-wrap="1">${html}</div>`, 'text/html');
|
||||
const container = doc.querySelector('[data-wrap]');
|
||||
if (!container) return html;
|
||||
|
||||
[...container.querySelectorAll('p')].forEach(p => {
|
||||
const inner = p.innerHTML;
|
||||
if (!/<br\s*\/?>/i.test(inner)) return;
|
||||
|
||||
const parts = inner.split(/<br\s*\/?>/i);
|
||||
const fragment = doc.createDocumentFragment();
|
||||
parts.forEach((part, index) => {
|
||||
if (part === '' && index === parts.length - 1) return;
|
||||
const newP = doc.createElement('p');
|
||||
newP.innerHTML = part;
|
||||
fragment.appendChild(newP);
|
||||
});
|
||||
p.replaceWith(fragment);
|
||||
});
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const alt = el.getAttribute('alt') ?? '';
|
||||
const src = el.getAttribute('src') ?? '';
|
||||
return src ? `` : '';
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('underline', {
|
||||
filter: ['u'],
|
||||
replacement: (content) => `<u>${content}</u>`,
|
||||
});
|
||||
|
||||
service.addRule('strikethrough', {
|
||||
filter: ['del', 's', 'strike'],
|
||||
replacement: (content) => `~~${content}~~`,
|
||||
});
|
||||
|
||||
service.addRule('horizontalRule', {
|
||||
filter: 'hr',
|
||||
replacement: () => '\n\n---\n\n',
|
||||
});
|
||||
|
||||
service.addRule('anchor', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'A') return false;
|
||||
const href = (node as HTMLAnchorElement).getAttribute('href');
|
||||
return Boolean(href);
|
||||
},
|
||||
replacement: (content, node) => {
|
||||
const el = node as HTMLAnchorElement;
|
||||
const href = el.getAttribute('href') ?? '';
|
||||
const title = el.getAttribute('title');
|
||||
return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
filter: 'members-only',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const parts: string[] = [];
|
||||
|
||||
el.childNodes.forEach(child => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? '').trim();
|
||||
if (text) parts.push(nbspToSpaces(text));
|
||||
return;
|
||||
}
|
||||
if (child instanceof HTMLElement) {
|
||||
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
||||
}
|
||||
});
|
||||
|
||||
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
function sanitizeContentHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||
function preserveLeadingIndent(markdown: string): string {
|
||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** 将普通 Markdown 片段转为 HTML */
|
||||
function parseMarkdownFragment(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
|
||||
}
|
||||
|
||||
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
|
||||
function prepareHtmlForMarkdown(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
||||
|
||||
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
doc.querySelectorAll('members-only').forEach(el => {
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const raw = body ? body.innerHTML : el.innerHTML;
|
||||
el.innerHTML = splitParagraphBreaks(raw);
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
||||
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
|
||||
}
|
||||
|
||||
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
||||
function normalizeListMarkerSpacing(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/^(\s*[-+*])\s+(?=\S)/gm, '$1 ')
|
||||
.replace(/^(\s*\d+\.)\s+(?=\S)/gm, '$1 ');
|
||||
}
|
||||
|
||||
/** 编辑器 HTML 转为 Markdown 源码 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const prepared = prepareHtmlForMarkdown(html);
|
||||
const raw = turndown.turndown(prepared).replace(/\n{3,}/g, '\n\n').trim();
|
||||
return normalizeListMarkerSpacing(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 源码转为编辑器 HTML。
|
||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
|
||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = re.exec(normalized);
|
||||
|
||||
while (match) {
|
||||
const before = normalized.slice(lastIndex, match.index);
|
||||
if (before.trim()) {
|
||||
result += parseMarkdownFragment(before);
|
||||
}
|
||||
|
||||
const innerMd = trimBlockBoundaryLines(match[1]);
|
||||
const innerHtml = innerMd.trim()
|
||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||
: '';
|
||||
result += `<members-only>${innerHtml}</members-only>`;
|
||||
lastIndex = re.lastIndex;
|
||||
match = re.exec(normalized);
|
||||
}
|
||||
|
||||
const tail = normalized.slice(lastIndex);
|
||||
if (tail.trim()) {
|
||||
result += parseMarkdownFragment(tail);
|
||||
}
|
||||
|
||||
return sanitizeContentHtml(result);
|
||||
}
|
||||
109
frontend/src/utils/markdownFormat.ts
Normal file
109
frontend/src/utils/markdownFormat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { applyTextareaChange } from './markdownIndent';
|
||||
|
||||
type ChangeHandler = (value: string) => void;
|
||||
|
||||
/** 在选区两侧包裹 Markdown 标记 */
|
||||
export function wrapMarkdownSelection(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const text = selected || placeholder;
|
||||
const insert = prefix + text + suffix;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
const newStart = selectionStart + prefix.length;
|
||||
const newEnd = newStart + text.length;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
}
|
||||
|
||||
/** 为当前行或选区行添加前缀(如引用) */
|
||||
export function prefixMarkdownLines(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', selectionEnd);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n').map(line => `${prefix}${line}`);
|
||||
const next = value.slice(0, lineStart) + lines.join('\n') + value.slice(lineEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + prefix.length,
|
||||
selectionEnd + prefix.length * lines.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换当前行标题级别(源码模式) */
|
||||
export function cycleMarkdownHeading(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', selectionStart);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const match = line.match(/^(#{2,6})\s+(.*)$/);
|
||||
let nextLine: string;
|
||||
if (!match) {
|
||||
nextLine = `## ${line}`;
|
||||
} else {
|
||||
const level = match[1].length;
|
||||
const body = match[2];
|
||||
nextLine = level >= 6 ? body : `${'#'.repeat(level + 1)} ${body}`;
|
||||
}
|
||||
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
|
||||
const offset = nextLine.length - line.length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + Math.max(0, offset),
|
||||
selectionStart + Math.max(0, offset),
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 插入登录可见区块模板 */
|
||||
export function insertMarkdownMembersOnly(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
|
||||
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** 在光标处插入链接 Markdown */
|
||||
export function insertMarkdownLink(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
url: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
|
||||
const insert = `[${selected}](${url})`;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + insert.length,
|
||||
selectionStart + insert.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
149
frontend/src/utils/markdownIndent.ts
Normal file
149
frontend/src/utils/markdownIndent.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
interface LineRange {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
/** 获取选区覆盖的整行文本范围 */
|
||||
function getLineRange(value: string, start: number, end: number): LineRange {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', end);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/** 多行整体增加一级缩进 */
|
||||
function indentBlock(value: string, lineStart: number, lineEnd: number): string {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const indented = block.split('\n').map(line => TAB_SPACES + line).join('\n');
|
||||
return value.slice(0, lineStart) + indented + value.slice(lineEnd);
|
||||
}
|
||||
|
||||
/** 多行整体减少一级缩进,返回新文本及选区偏移 */
|
||||
function outdentBlock(
|
||||
value: string,
|
||||
lineStart: number,
|
||||
lineEnd: number,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): { next: string; newStart: number; newEnd: number } {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
let cursor = lineStart;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
|
||||
const outdented = lines.map(line => {
|
||||
const match = line.match(/^ {1,4}/);
|
||||
const removed = match ? match[0].length : 0;
|
||||
if (removed > 0) {
|
||||
if (selectionStart > cursor) {
|
||||
newStart -= Math.min(removed, selectionStart - cursor);
|
||||
}
|
||||
if (selectionEnd > cursor) {
|
||||
newEnd -= Math.min(removed, selectionEnd - cursor);
|
||||
}
|
||||
}
|
||||
cursor += line.length + 1;
|
||||
return line.replace(/^ {1,4}/, '');
|
||||
}).join('\n');
|
||||
|
||||
return {
|
||||
next: value.slice(0, lineStart) + outdented + value.slice(lineEnd),
|
||||
newStart: Math.max(lineStart, newStart),
|
||||
newEnd: Math.max(lineStart, newEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在光标处插入文本并恢复选区 */
|
||||
export function applyTextareaChange(
|
||||
textarea: HTMLTextAreaElement,
|
||||
nextValue: string,
|
||||
cursorStart: number,
|
||||
cursorEnd = cursorStart,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 在 textarea 光标处插入文本 */
|
||||
export function insertAtCursor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
currentValue: string,
|
||||
insertText: string,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const next = currentValue.slice(0, selectionStart) + insertText + currentValue.slice(selectionEnd);
|
||||
const cursor = selectionStart + insertText.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** Markdown 源码 Tab / Shift+Tab 缩进 */
|
||||
export function handleMarkdownTabKey(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const { lineStart, lineEnd } = getLineRange(value, selectionStart, selectionEnd);
|
||||
const hasSelection = selectionStart !== selectionEnd;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const multiLine = hasSelection && block.split('\n').length > 1;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (multiLine) {
|
||||
const { next, newStart, newEnd } = outdentBlock(value, lineStart, lineEnd, selectionStart, selectionEnd);
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineText = value.slice(lineStart, selectionStart);
|
||||
const trailingMatch = lineText.match(/ {1,4}$/);
|
||||
if (trailingMatch) {
|
||||
const removeLen = trailingMatch[0].length;
|
||||
const next = value.slice(0, selectionStart - removeLen) + value.slice(selectionStart);
|
||||
applyTextareaChange(textarea, next, selectionStart - removeLen, selectionEnd - removeLen, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const leadingMatch = value.slice(lineStart, selectionStart).match(/^ {1,4}/);
|
||||
if (leadingMatch) {
|
||||
const removeLen = leadingMatch[0].length;
|
||||
const next = value.slice(0, lineStart) + value.slice(lineStart + removeLen);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart - removeLen,
|
||||
selectionEnd - removeLen,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiLine) {
|
||||
const next = indentBlock(value, lineStart, lineEnd);
|
||||
const lineCount = value.slice(lineStart, lineEnd).split('\n').length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + TAB_SPACES.length,
|
||||
selectionEnd + TAB_SPACES.length * lineCount,
|
||||
onChange,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.slice(0, selectionStart) + TAB_SPACES + value.slice(selectionEnd);
|
||||
applyTextareaChange(textarea, next, selectionStart + TAB_SPACES.length, selectionStart + TAB_SPACES.length, onChange);
|
||||
}
|
||||
Reference in New Issue
Block a user