58 Commits

Author SHA1 Message Date
20aee9c566 chore: 清理 SSR 改造后的历史遗留死代码与误提交产物
- 删除图形验证码整条死链:service/captcha.go、APICaptcha、
  GET /api/captcha、CaptchaService 装配、ErrCaptchaInvalid 及前端
  api.captcha()(注册/登录已改用邮箱验证码,Verify 无任何调用方)
- 删除 GET /api/posts/hot 死路由:APIHotPosts + PostService.HotPosts
  (热门排序走 /api/posts?sort=hot)
- 删除 GET /api/posts/:id/revisions/:revId 死路由:
  APIPostRevisionDetail + PostService.GetRevision + ErrRevisionNotFound
  及前端 api.postRevision()(历史面板仅用列表接口)
- 停止跟踪误提交的 27.5MB Linux 二进制 jiang13-linux-amd64 与
  .vite 依赖缓存;.gitignore 补充对应规则并移除已失效的
  embed_static 条目
2026-09-11 00:39:58 +08:00
45cc864971 feat(ssr): 扩展 SSR 覆盖至 /page/:slug 与 /user/:id
- feedPaths 白名单加入自定义单页与用户主页(兼容 .html 伪静态)
- head.ts 新增 buildPageHead(ogType=article, WebPage JSON-LD)
  与 buildUserHead(ogType=profile, Person JSON-LD)
- feedData 新增 loadPageData / loadUserData,并行取页面/用户
  数据 + 壳层,404 走 buildNotFoundHead + noindex
- feedSeed 按 useSessionResource 的 key 约定 seed:
  sitepage:${slug}、user:${id}、user:${id}:posts:1:${size},
  使 SitePageView / UserProfilePage 首帧直接命中快照渲染
- MainLayout postOutline 首帧派生扩展 /page/:slug 分支,
  文章目录随正文一同 SSR
- 验证:两页 SSR HTML 含完整内容与 head,0 hydration 错误,
  hydration 后仅剩 pageview 请求;404 正确;已有页面无回归
2026-09-10 19:26:14 +08:00
3c6d8cb891 feat(ssr): 帖子页全量 SSR——作者卡/目录首帧服务端渲染,消除冗余首屏请求
- 帖子快照携带作者主页资料与统计(authorProfile/authorStats),
  SSR 端与客户端预热均取 /api/users/:id;PostAuthorCard 首帧直接渲染,
  不再挂载后请求,统计数字不再显示 — 占位
- MainLayout 首帧从已 seed 的帖子快照派生右栏 outline(作者卡+文章目录),
  修复 outline 仅靠 effect 上报导致 SSR 不渲染右栏的问题
- 壳层首屏请求门控:useAuth 有种子跳过 /api/me、branding 缓存命中不重拉、
  侧栏三个区块首屏用 seed 缓存、未读数首屏跳过即时请求(轮询/事件刷新保留)
- serverDom(linkedom) 修复:DOMParser 片段包裹、属性顺序、SVG 自闭合展开
- 验证:首页与帖子页 hydration 后仅剩 pageview 上报,0 hydration 错误
2026-09-10 05:04:31 +08:00
2392768868 feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
2026-09-09 20:53:28 +08:00
3702e3d1f2 chore: Tailwind CSS 3.4 迁移到 4.x(CSS-first 配置 + @tailwindcss/vite)
- 升级 tailwindcss@4.3 + @tailwindcss/vite,移除 postcss/autoprefixer/tailwindcss-animate
- tailwindcss-animate 替换为 tw-animate-css(v4 兼容的 animate-in/out 工具类)
- global.css 改用 @import "tailwindcss" + @theme inline 映射 shadcn 设计令牌
- @custom-variant dark 替代 darkMode:'class';@source 显式纳入 pages/ 目录
- 删除 tailwind.config.ts / postcss.config.js,components.json 同步 v4 约定
- vite.config.ts 接入 tailwindcss 插件,__dirname 改 import.meta.dirname
2026-09-09 02:54:56 +08:00
230006a905 chore: Docker 双服务 + Makefile/build.ps1 双进程目标 + README 架构更新
- Dockerfile 改为 Node 24 运行时:Vike 产物 + Go API 二进制共用镜像
- docker-compose 双容器:web(:3000 对外 SSR) + api(:3001 内部,持 /data 卷)
- entrypoint 改用 gosu 降权(Debian 基础镜像),健康检查用 node fetch
- Makefile/build.ps1:build 不再打包前端,新增 serve(本地双进程)与 web-build
- README 同步双服务架构、端口/环境变量、部署与开发说明
2026-09-09 02:48:28 +08:00
ba39376a0e refactor: Go 端收缩,移除 SPA 服务与 embed_static
- 删除 embed_static(内嵌 SPA 静态资源)与 handler/ssr_home.go、ssr_icons.go
- router 移除前端页面服务,NoRoute 统一返回 404 JSON
- seo.go 仅保留 robots.txt / sitemap.xml / favicon.ico
- 数据聚合逻辑迁移到 handler/home_boot.go(/api/home-boot)
- 启动日志改为提示前端由 Node 渲染服务器提供
2026-09-09 02:41:53 +08:00
1ce0f123fa feat: Node 渲染服务器(express + renderPage + API 反代)
- server/index.mjs:生产模式 Express 服务,静态资源 immutable 缓存
- /api /uploads /media 等前缀反代到 Go API,xfwd 透传客户端 IP
- Vike renderPage 承接 SSR,渲染失败返回 500 兜底页
- /healthz 健康检查端点
2026-09-09 02:41:39 +08:00
c99238c1e7 feat: Vike +data 服务端取数 / SEO Head 移植 / 404 错误页 / 服务端 DOM 模拟
- feedData.ts 聚合首页/板块/帖子详情数据,缺失内容 throw render(404)
- Go 侧 spa_meta/seo 的 head 生成逻辑移植到 src/ssr/head.ts
- _error 页复用站点壳渲染 404,+data 从 abortReason 恢复壳层数据
- linkedom 模拟服务端 DOM,DOMPurify 封装为 SSR 透传的 sanitizeHtml
- 新增 /api/home-boot 聚合端点(handler/home_boot.go)供 SSR 冷启动灌缓存
2026-09-09 02:41:30 +08:00
e71c77b5ca feat: Vike 骨架(pages feed+spa / renderer 双端入口 / AppShell 拆分 / serverClient)
vite.config 接入 vike 插件并改产出 dist/client+dist/server;feed 白名单 route 匹配 /、/board/:id、/post/:id(.html);spa catch-all ssr:false;onRenderHtml renderToReadableStream 等 allReady 输出完整 HTML 并带主题/缩放脚本;onRenderClient 沿用样式表等待+冷启动预热+hydrate 回退;getHomeStoreState 作用域 + hydrating 变体入 RequestScope;删除 index.html 与 main.tsx
2026-09-09 01:21:58 +08:00
27132e89c2 feat: SSR 安全防卫与缓存作用域化(branding/limits/pages/auth/session)
theme/useTheme 服务端防卫 window/document;branding/limits/pages 缓存读请求作用域、seed 只写作用域;homeBoot 拆出 seedHomeBoot 与帖子详情快照构建器供 prefetchRoute 复用;getHomeStoreState 改返回 state 修复命令式访问
2026-09-09 01:05:12 +08:00
86a317c862 feat: SSR 请求级作用域(AsyncLocalStorage)+ homeStore 工厂化 + layoutCache 作用域适配 2026-09-09 00:35:19 +08:00
52d328ffc3 chore: 工具链升级到最新版本(Vite 8 / React 19 / react-router 8 / TS 7 / Go 1.26 依赖更新)
- 前端:vite ^8.2.2、react/react-dom ^19.2.8、react-router ^8.3.1(移除 react-router-dom)、typescript ^7.0.2、zustand ^5.0.15;适配升级带来的类型变化(react-hook-form Resolver、ProseMirror node.parent、tiptap focus 参数、timer undefined 等)
- 后端:升级 gin/jwt/minio-go/gorm/modernc-sqlite/x-crypto 等直接依赖至最新;gowebp v0.1.1 已是最新版本
- tsconfig 移除 baseUrl,paths 改为相对路径以兼容 TS7
2026-09-09 00:14:12 +08:00
711cd8b759 feat: 帖子全局/板块置顶支持权重排序,优化列表与详情渲染 2026-09-08 23:54:03 +08:00
de2f5e8be8 docs: changelog 排版优化,每个版本统一分隔线 + 📦 图标 + 日期格式统一 2026-09-02 19:51:12 +08:00
5c774f9a83 docs: 更新 changelog 为 v1.1.8 2026-09-02 19:49:13 +08:00
f3f50d7e90 chore: bump version to 1.1.8 2026-09-02 19:40:35 +08:00
67bef6dc08 chore: 同步 Cursor 构建规则到 Trae,优化 .gitignore
- 新增 .trae/rules/build-scripts.mdc,与 Cursor 的 .cursor/rules/build-scripts.mdc 保持一致
- .gitignore 从忽略整个 .trae/ 改为仅忽略 .trae/documents/、.trae/chat/、.trae/image/ 等临时产物
- 保留 .trae/rules/ 可被 git 追踪,规则可跨机器同步
- 取消追踪已存在的 .trae/documents/ 临时规划文档
2026-09-02 19:34:49 +08:00
23e304ac53 chore: 在 .gitignore 中添加 .trae/ 目录 2026-09-02 19:30:33 +08:00
cad53942e8 fix: 修复消息未读数同步和帖子详情快照逻辑
- MessagesPage: 新增 updateConvUnread 统一更新会话未读数及 session 缓存,
  缓存命中时仍调用 markConversationRead 并触发未读刷新,
  全部标为已读时同步更新 session 缓存避免 stale 数据
- PostDetailPage: 仅 POP 导航类型使用 session 快照恢复,
  非 POP 导航和下拉刷新始终强制重拉确保拿到最新数据
2026-09-02 19:29:01 +08:00
76be8926f2 feat: 完善表情选择与评论富文本展示
发帖编辑器支持 OwO 贴纸;评论区复用文章列表、段落与代码块阅读态,并优化表情面板间距与悬停反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:57:41 +08:00
4d93e455f9 fix: 私信表情插入编辑框,不再作为图片附件发送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:12:33 +08:00
c958dcfd80 chore: 发布 Docker 镜像版本 1.1.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:01:36 +08:00
84bfbd9a3e fix: 帖子详情右栏默认头像与全站配色一致
作者卡占位头像改为深绿底白字,与 post-avatar 默认样式统一。
2026-09-02 02:50:23 +08:00
500e452c2f feat: 重设计私信页并优化开源展柜体验
私信改为飞书风布局,支持缩略图表情/图片、按会话草稿与用户搜索发起会话;主页发私信直达消息页。开源展柜修复空列表粘滞并改版目录式 UI。
2026-09-02 02:45:29 +08:00
1c0f7ede55 fix: 待审通知实时回填审核态并重构消息页体验 2026-09-02 01:39:27 +08:00
2e9c42a34c chore: 发布 Docker 镜像版本 1.1.6
同步更新日志与 README 下载链接;含编辑器链接/图片选择器与首页最新活动排序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:02:00 +08:00
dff60dde6a fix: 首页默认排序改为最近活动时间线
reply 按发帖与最后评论的较晚时间混排,零回复新帖不再沉底;默认文案改为「最新」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:54:44 +08:00
ea9e0058d4 fix: 链接对话框站内搜索收进高级选项
默认只显示网址与文字;展开高级选项后再搜站内内容,点选时同步更新链接文字。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:33:24 +08:00
d5aa36416c feat: 增强编辑器链接对话框并统一图片插入选择器
链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:22:03 +08:00
2159280af5 style: 三栏主滚动区统一细绿色滚动条
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 19:12:24 +08:00
bc9a031890 feat: 单页详情右侧栏与帖子共用目录布局
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:34:59 +08:00
0d81787125 fix: 从正文同步派生帖子目录,避免预取缓存竞态导致空树
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:25:42 +08:00
68713c053b docs: 增加更新日志并指向 Gitea Release 下载
让 Docker 与 Windows/Linux 用户能对照同一版本号获取新包。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:09:41 +08:00
94823c0821 chore: 发布 Docker 镜像版本 1.1.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:16:29 +08:00
b608ec85ba fix: 桌面隐藏导航汉堡,恢复评论数与共 N 条右对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:11:29 +08:00
6048d71759 fix: 帖行评论数勿贴右缘,手机 SSR 顶栏/页脚与 React 同构
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:06:57 +08:00
37d14a22d5 fix: 发版后软刷新检测入口壳并在 chunk 404 时硬刷新
避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:53:39 +08:00
a02d103cfe fix: 排序栏 count 紧跟标签,FeedSortBar 与 SSR 统一为 a 标签
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:48:18 +08:00
21c6233fc0 feat: 排序标签强制刷新、推荐帖仅 featured,软刷新同步 limits/品牌文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:32:21 +08:00
4ca5c17e2e fix: 拆除爬虫专用 HTML,并补齐 /favicon.ico 与 head 图标注入
人机统一走 SPA/首页 SSR;约定路径与 link rel=icon 便于爬虫识别站点图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:15:06 +08:00
cf7a37eccc chore: 忽略 lucide path 抽取临时文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:08:47 +08:00
44eed97fe0 feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动
补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:07:38 +08:00
9e134916a4 fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
2026-09-01 04:56:10 +08:00
ba60a9b1c4 feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:59:01 +08:00
132ca5c82c fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求
点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:50:33 +08:00
185649db7c fix: 增加手机端排序栏顶部间距
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 23:56:40 +08:00
9d926750b2 chore: 发布 Docker 镜像 1.1.4
含城市中文映射补全与监控概览双通道口径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 09:07:50 +08:00
a91d917fa9 fix: 对齐监控概览双通道口径文案与诊断抽样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:58:03 +08:00
34f3d088ad fix: 补全 IP2Location 中国城市中文映射并按省消歧
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:40:12 +08:00
c6ce44f8b4 chore: 发布 Docker 镜像 1.1.3
包含网站监控与独立 monitor.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:58:54 +08:00
429956c594 feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:55:45 +08:00
16b6a18096 chore: 发布 Docker 镜像 1.1.2
包含可选社区上报、精选展柜与仪表盘支持条。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:26:26 +08:00
95c2154087 feat: 可选社区上报与官方精选展柜
默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:22:59 +08:00
0abfb32194 chore: 发布 Docker 镜像 1.1.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 00:25:20 +08:00
bcfd306643 feat: 评论管理操作收进更多菜单,减少管理员界面杂乱
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 00:08:16 +08:00
335b708b04 chore: 发布 Docker 镜像 1.1.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 23:27:52 +08:00
a83f8bd670 feat: 帖子管理收进右上角菜单,手机端评论输入默认折叠
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 22:58:55 +08:00
214 changed files with 24294 additions and 6922 deletions

View File

@@ -14,7 +14,6 @@ tmp-cookie.txt
dist/ dist/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
embed_static/static/spa/
node_modules/ node_modules/
.vite/ .vite/

7
.gitignore vendored
View File

@@ -8,11 +8,12 @@
/node_modules/ /node_modules/
/frontend/node_modules/ /frontend/node_modules/
/frontend/dist/ /frontend/dist/
/embed_static/static/spa/ /.vite/
# Go 编译产物 # Go 编译产物
/dist/ /dist/
*.exe *.exe
/jiang13-*
# 临时文件 # 临时文件
tmp-cookie.txt tmp-cookie.txt
@@ -20,6 +21,10 @@ tmp-cookie.txt
# 编辑器 / OS # 编辑器 / OS
.idea/ .idea/
.vscode/ .vscode/
.trae/documents/
.trae/chat/
.trae/image/
*.swp *.swp
Thumbs.db Thumbs.db
.DS_Store .DS_Store

View File

@@ -1,151 +0,0 @@
# 表情效果调整计划
## 问题分析
当前实现存在 5 个问题,根因如下:
### 1. 插入表情后有选择状态(蓝色高亮)
- **根因**: Tiptap `setImage` 插入图片节点后,节点处于 "node-selected" 状态,显示蓝色选中框
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L155-163
### 2. 输入框表情太大
- **根因**: CSS 选择器 `img[src^="data:image/svg"]` 只匹配旧 SVG data URI,不匹配新的 AVIF URL(`/stickers/tieba/tb_01.avif`),导致表情图片无尺寸约束,以原始大尺寸渲染
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5584-5603
### 3. 插入表情后光标换行
- **根因**: `ArticleImage.configure({ inline: false })` 使图片为块级节点,插入后自动换行
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L92
### 4. 表情栏目太宽松
- **根因**: Grid 仅 6 列,gap 4px,padding 10px
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5502-5510
### 5. 颜文字太小
- **根因**: `.sticker-picker-text` 的 `font-size: 8px`,极小
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5606-5616
## 改动方案
### 文件 1: CommentEditor.tsx
**改动 A — 图片改为内联** (L92):
```typescript
// 旧
ArticleImage.configure({ inline: false, allowBase64: true }),
// 新
ArticleImage.configure({ inline: true, allowBase64: true }),
```
**改动 B — insertSticker 插入后取消选中和换行** (L155-163):
```typescript
const insertSticker = useCallback((sticker: Sticker) => {
if (!editor) return;
if (sticker.type === 'text' && sticker.text) {
editor.chain().focus().insertContent(sticker.text).run();
} else if (sticker.url) {
// 插入内联图片 + 尾随零宽空格,确保光标在图片后面而非选中图片
editor.chain().focus().insertContent([
{ type: 'image', attrs: { src: sticker.url, alt: sticker.name } },
{ type: 'text', text: '\u200b' },
]).run();
}
setShowSticker(false);
}, [editor]);
```
> 用 `insertContent` + 零宽空格替代 `setImage`,避免 node-selected 状态,光标自然落在图片后。
### 文件 2: StickerPicker.tsx
**改动 C — 键盘导航列数匹配新网格** (L44):
```typescript
// 旧
const cols = 6;
// 新
const cols = 8;
```
### 文件 3: global.css
**改动 D — 表情选择器密度提升** (L5502-5510):
```css
.sticker-picker-grid {
display: grid;
grid-template-columns: repeat(8, 1fr); /* 6 → 8 */
gap: 2px; /* 4px → 2px */
padding: 6px; /* 10px → 6px */
overflow-y: auto;
flex: 1;
min-height: 0;
}
```
**改动 E — 选择器项缩小** (L5512-5522):
```css
.sticker-picker-item {
padding: 2px; /* 4px → 2px */
/* 其余不变 */
}
```
**改动 F — 颜文字字体放大** (L5606-5616):
```css
.sticker-picker-text {
font-size: 14px; /* 8px → 14px */
line-height: 1.4; /* 1.2 → 1.4 */
/* 其余不变 */
}
```
**改动 G — 替换旧 SVG 选择器为通用贴纸选择器** (L5584-5603):
删除旧的 `img[src^="data:image/svg"]` 选择器,替换为基于 `/stickers/` 路径的选择器:
```css
/* 编辑器内贴纸 img 尺寸约束 */
.comment-editor .article-prosemirror img[src*="/stickers/"],
.comment-editor .article-editor-content img[src*="/stickers/"] {
display: inline-block;
width: 28px;
height: 28px;
vertical-align: middle;
margin: 0 1px;
border-radius: 4px;
object-fit: contain;
}
/* 评论正文中的贴纸 img */
.floor-body img[src*="/stickers/"],
.comment-body img[src*="/stickers/"] {
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
margin: 0 1px;
background: transparent;
border-radius: 4px;
object-fit: contain;
}
```
**改动 H — 移动端网格也改为 8 列** (L5619-5624):
```css
@media (max-width: 640px) {
.sticker-picker { max-height: 240px; }
.sticker-picker-grid { grid-template-columns: repeat(6, 1fr); } /* 移动端 6 列 */
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
}
```
> 移动端保持 6 列(屏幕窄),桌面端 8 列。
## 不改动
- `ArticleImageExtension.tsx` — 无需修改,`inline: true` 通过 `configure()` 传入即可
- `kaomoji.ts` / `emojiData.ts` / `hot.ts` — 数据层不变
- `CommentContent.tsx` — 渲染层不变(CSS 覆盖即可)
- PostEditor(帖子编辑器)— 不受影响,仍使用 `inline: false`
## 验证
1. `npm run build` 无报错
2. 浏览器验证:
- 选择表情后插入无蓝色选中框
- 表情在输入框中显示 28px,内联在文字中
- 插入表情后光标紧跟表情后方,不换行
- 表情选择器 8 列密度更高
- 颜文字标签内字体清晰可读

View File

@@ -1,597 +0,0 @@
# 评论表情包改造 + 富文本评论编辑器计划
## 一、摘要
将评论系统从「纯文本 textarea + Unicode Emoji」升级为「Tiptap 富文本编辑器 + 姜十三专属 SVG 贴纸」,实现:
1. 去除所有 Unicode Emoji,替换为自定义 SVG 贴纸(懒加载)
2. 姜十三专属表情包:萌系吉祥物表情 + 中文网络流行语文字气泡(混合风格)
3. 评论复用帖子编辑器核心能力(精简变体),支持代码块、链接、图片、格式化等
---
## 二、当前状态分析
### 2.1 评论输入:纯文本 textarea
[CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) 使用 `<textarea>` 输入评论,功能包括:
- `@` 用户提及(textarea 选区扫描 + API 搜索用户)
- Unicode Emoji 面板([EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) + [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts))
- 隐私评论开关
- 无富文本格式化能力
### 2.2 评论渲染:纯文本转义
[CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) 通过 [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) 的 `highlightMentions()` 渲染评论:
- `escapeWithBreaks()` 转义 HTML 并将 `\n` 转为 `<br>`
- 正则匹配 `@username` 包裹为可点击 `<span class="mention">`
- 不支持 HTML/Markdown 渲染
### 2.3 评论编辑:纯 textarea
[CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) 编辑模式使用 `<textarea>` 直接修改文本。
### 2.4 帖子编辑器:Tiptap 富文本
[ArticleEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/ArticleEditor.tsx) 是功能完整的 Tiptap 编辑器(900 行),包含:
- 富文本模式 + Markdown 模式切换
- 工具栏:标题/加粗/斜体/下划线/删除线/分割线/引用/列表/代码块/表格/链接/图片/图组
- 门控区块:登录可见/回复可见/积分可见
- 全屏模式
- `forwardRef` 暴露 `getHTML()` / `isEmpty()` / `focus()`
- 使用 `DOMPurify` + `POST_CONTENT_PURIFY_CONFIG` 净化 HTML
### 2.5 后端评论存储
- [models.go](file:///c:/Users/freefire/Documents/jiang13-forum/model/models.go#L143-L164): `Comment.Content` 字段类型为 `text`,存储纯文本
- [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176): `Create()` 仅做 `TrimSpace` + 敏感词过滤,**无 HTML 净化**
- [handlers.go](file:///c:/Users/freefire/Documents/jiang13-forum/handler/handlers.go#L508-L530): `APICreateComment` 从 FormData 取 `content` 字段
- [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go): `SanitizePostHTML` 已有 HTML 白名单策略(bluemonday),但**仅用于帖子,未用于评论**
### 2.6 表情数据
[emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) 定义约 160 个 Unicode Emoji 字符,无分类、无搜索。
---
## 三、修改方案
### Part A:姜十三专属 SVG 贴纸系统
#### A1. 贴纸数据定义
**新建** `frontend/src/data/stickers.ts`
```typescript
export interface Sticker {
id: string; // 唯一ID,如 "j13-happy"
name: string; // 名称,如 "开心"
category: StickerCategory;
aliases?: string[]; // 搜索别名
svg: string; // 完整 SVG 字符串(含 viewBox 0 0 64 64)
}
export type StickerCategory = '热门' | '姜十三' | '文字气泡';
export const STICKER_CATEGORIES: StickerCategory[] = ['热门', '姜十三', '文字气泡'];
export const STICKERS: Sticker[] = [ /* ... */ ];
```
**贴纸内容设计(约 40 个):**
| 分类 | 数量 | 内容 |
|------|------|------|
| 姜十三 | 16 | 萌系姜色(ginger色 #D4A574)圆脸吉祥物,额头带"13"标记,各种表情:开心/大笑/哭泣/生气/惊讶/思考/点赞/心心眼/睡觉/疑惑/酷/捂脸/送花/鼓掌/加油/拜托 |
| 文字气泡 | 16 | 圆角气泡 + 中文网络流行语:666/大佬/同问/给力/沙发/学习了/已赞/佩服/妙啊/牛批/感谢/收藏了/围观/催更/瑞思拜/芜湖 |
| 热门 | 8 | 从上述两类中精选最常用的 8 个 |
**SVG 设计规范:**
- `viewBox="0 0 64 64"` 统一尺寸
- 所有 fill 使用内联颜色(不依赖 CSS 变量)
- 姜十三吉祥物主色系:`#D4A574`(姜色)/ `#FFF3E0`(浅姜)/ `#E8B87C`(深姜)
- 文字气泡:`#FF6B6B`(红)/ `#4ECDC4`(青)/ `#FFE66D`(黄)/ `#95E1D3`(绿)四色循环
- 线条圆润,stroke-linecap: round
#### A2. SVG 贴纸渲染组件
**新建** `frontend/src/components/emoji/StickerSvg.tsx`
```typescript
interface StickerSvgProps {
id: string;
size?: number; // 默认 28
className?: string;
}
```
- 从 `STICKERS` 查找对应 id,渲染 `dangerouslySetInnerHTML={{ __html: sticker.svg }}`
- SVG 已自包含 fill 颜色,无需额外样式
#### A3. 贴纸选择器(懒加载)
**新建** `frontend/src/components/emoji/StickerPicker.tsx`
```typescript
interface StickerPickerProps {
onSelect: (stickerId: string) => void;
}
```
**UI 结构:**
```
┌──────────────────────────────┐
│ [热门] [姜十三] [文字气泡] │ ← 分类 Tab
├──────────────────────────────┤
│ [🙂] [😂] [❤️] [👍] [🎉] ... │ ← SVG 贴纸网格(6列桌面/4列移动端)
└──────────────────────────────┘
```
**懒加载策略:**
- 贴纸数据按分类拆分为独立 chunk:`data/stickers/j13.ts`、`data/stickers/text.ts`、`data/stickers/hot.ts`
- `StickerPicker` 使用 `React.lazy()` + `Suspense` 按需加载当前分类
- 切换分类时才加载对应 chunk,首次打开只加载"热门"分类
- 每个贴纸 SVG 在组件挂载时渲染(已在数据中内联,无需额外网络请求)
**交互:**
- 分类 Tab 点击切换,带下划线动画
- 贴纸 hover: `transform: scale(1.15)`, 0.1s 过渡
- 点击贴纸触发 `onSelect(sticker.id)`
- 键盘导航:方向键浏览 + Enter 选中
#### A4. 删除旧 Emoji 系统
- **删除** [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts)(`EMOJI_LIST` 导出)
- **删除** [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx)
- **保留** `.emoji-picker*` CSS 类(避免影响其他可能的引用),新增 `.sticker-picker*` 类
---
### Part B:评论富文本编辑器(精简变体)
#### B1. 创建 CommentEditor 组件
**新建** `frontend/src/components/CommentEditor.tsx`
不直接复用 ArticleEditor(900 行,含全屏/Markdown/门控区块等评论不需要的功能),而是创建独立的精简 Tiptap 编辑器,**复用 ArticleEditor 的扩展组件**。
```typescript
export interface CommentEditorHandle {
getHTML: () => string;
isEmpty: () => boolean;
focus: () => void;
}
interface CommentEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
}
```
**使用的 Tiptap 扩展(复用现有):**
- `StarterKit`(含 heading H2-H4、bold/italic/strike、blockquote、bulletList/orderedList、horizontalRule)
- `Underline`(来自 @tiptap/extension-underline,已安装)
- `Link`(来自 @tiptap/extension-link,已安装)
- `ArticleCodeBlock`(来自 [ArticleCodeBlockExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleCodeBlockExtension.tsx),复用)
- `Placeholder`(来自 @tiptap/extension-placeholder,已安装)
- `TabIndent`(来自 [TabIndentExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/TabIndentExtension.tsx),复用)
- `ArticleImage`(来自 [ArticleImageExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleImageExtension.tsx),复用,用于插入图片)
**不使用的扩展(评论场景不需要):**
- ~~TableKit~~(表格)
- ~~ImageGroup~~(图组)
- ~~MembersOnly / ReplyOnly / PointsOnly~~(门控区块)
- ~~ClearFloatParagraph~~(清除浮动段落)
- ~~Markdown 模式~~
- ~~全屏模式~~
**工具栏按钮(精简版):**
```
[H] [B] [I] [U] [S] [引用] [列表] [有序列表] [代码块] [链接] [图片] [贴纸]
```
**贴纸集成:**
- 工具栏增加贴纸按钮(Lucide `Sticker` 图标)
- 点击弹出 `StickerPicker`
- 选中贴纸后,将 SVG 作为 inline `<img>` 插入编辑器:
```typescript
const sticker = STICKERS.find(s => s.id === id);
const dataUri = `data:image/svg+xml,${encodeURIComponent(sticker.svg)}`;
editor.chain().focus().setImage({ src: dataUri, alt: sticker.name }).run();
```
**HTML 净化:**
- 使用 `DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG)` 与 ArticleEditor 一致
- `POST_CONTENT_PURIFY_CONFIG` 来自 [postContent.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/postContent.ts),复用现有配置
#### B2. 改造 CommentBox
**修改** [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx)
**主要变更:**
1. 用 `CommentEditor` 替换 `<textarea>`
2. 用 `CommentEditorHandle` ref 替代 `textareaRef`
3. `content` 状态存储 HTML 而非纯文本
4. 移除 `EmojiPicker` 导入和使用,贴纸功能已集成到 `CommentEditor`
5. 移除 `owoRef` 和 `showEmoji` 状态
6. `@` 提及功能:暂时保留为文本输入(在编辑器中输入 `@username`,渲染时由 `processCommentHtml` 处理高亮),编辑器内自动补全作为后续增强
7. `insertEmoji` 改为 `insertSticker`,调用 `CommentEditor` ref 方法
8. 隐私评论开关保留
9. 发送时 `content` 为 HTML,直接传给 API
**改动前:**
```tsx
<textarea ref={textareaRef} value={content} onChange={handleChange} ... />
<button ref={owoRef} onClick={() => setShowEmoji(v => !v)}>OwO</button>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
```
**改动后:**
```tsx
<CommentEditor ref={editorRef} value={content} onChange={setContent} placeholder="说点什么吧…" />
```
#### B3. 改造评论编辑模式
**修改** [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297)
编辑模式从 `<textarea>` 改为 `CommentEditor`:
```tsx
// 改动前
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={3} />
// 改动后
<CommentEditor value={editText} onChange={setEditText} placeholder="编辑评论…" />
```
需要 import `CommentEditor`,并在 `handleSave` 中提交 HTML 内容。
---
### Part C:评论内容渲染
#### C1. 更新 content.ts 支持富文本
**修改** [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts)
当前 `highlightMentions()` 处理纯文本(转义 HTML + 换行 + @高亮)。需要新增 HTML 处理能力。
```typescript
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
/** 判断内容是否为 HTML(包含常见 HTML 标签) */
function isHtmlContent(text: string): boolean {
return /<(?:p|div|span|br|h[1-6]|ul|ol|li|pre|code|blockquote|a|img|table|strong|em|u|s)\b/i.test(text);
}
/** 在 HTML 文本节点中高亮 @ 提及(DOM 遍历,避免破坏标签) */
function processMentionsInHtml(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
for (const textNode of textNodes) {
const text = textNode.textContent ?? '';
if (!/@[\w\u4e00-\u9fa5_-]/.test(text)) continue;
const frag = document.createDocumentFragment();
const parts = text.split(/(@[\w\u4e00-\u9fa5_-]+)/);
for (const part of parts) {
const m = part.match(/^@([\w\u4e00-\u9fa5_-]+)$/);
if (m) {
const span = document.createElement('span');
span.className = 'mention';
span.setAttribute('data-name', m[1]);
span.setAttribute('role', 'link');
span.setAttribute('tabindex', '0');
span.textContent = part;
frag.appendChild(span);
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
}
textNode.parentNode?.replaceChild(frag, textNode);
}
return div.innerHTML;
}
/** 渲染评论内容:HTML 净化 + @提及高亮,兼容旧版纯文本 */
export function renderCommentContent(content: string): string {
if (isHtmlContent(content)) {
// 新版 HTML 评论
const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG);
return processMentionsInHtml(sanitized);
}
// 旧版纯文本评论(向后兼容)
return escapeWithBreaks(content).replace(
/@([\w\u4e00-\u9fa5_-]+)/g,
'<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
);
}
```
保留原 `highlightMentions()` 函数不删除(可能有其他引用),新增 `renderCommentContent()`。
#### C2. 更新 CommentContent 组件
**修改** [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx)
```tsx
import { renderCommentContent } from '../utils/content';
// 改动前
dangerouslySetInnerHTML={{ __html: highlightMentions(content) }}
// 改动后
dangerouslySetInnerHTML={{ __html: renderCommentContent(content) }}
```
评论内容中的贴纸 `<img>` 标签会被 `POST_CONTENT_PURIFY_CONFIG` 保留(已允许 `<img>` + `src`),自然渲染为 SVG 图。
---
### Part D:后端评论 HTML 净化
#### D1. 评论创建时净化
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176)
```go
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
content := SanitizePostHTML(strings.TrimSpace(in.Content)) // 新增 HTML 净化
content = s.filter.Filter(content) // 敏感词过滤
// ... 其余不变
}
```
#### D2. 评论更新时净化
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376)
```go
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
// ...
content = SanitizePostHTML(strings.TrimSpace(content)) // 新增
content = s.filter.Filter(content) // 敏感词过滤
// ... 其余不变
}
```
`SanitizePostHTML` 已在 [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go) 中定义,使用 bluemonday 白名单策略,允许 Tiptap 产出的 HTML 标签和属性,禁止 `<script>`、`<style>` 等。
---
### Part E:CSS 样式
#### E1. 贴纸选择器样式
**修改** [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) 区域
新增样式块(在现有 `.emoji-picker*` 样式之后):
```css
/* 贴纸选择器 */
.sticker-picker {
display: flex;
flex-direction: column;
margin-top: 8px;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
background: var(--j13-bg-surface);
max-height: 280px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.sticker-picker-tabs {
display: flex;
border-bottom: 1px solid var(--j13-border-light);
padding: 0 8px;
}
.sticker-picker-tab {
border: none;
background: none;
padding: 8px 12px;
font-size: 13px;
color: var(--color-text-3);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.sticker-picker-tab.active {
color: var(--j13-green);
border-bottom-color: var(--j13-green);
}
.sticker-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: 10px;
overflow-y: auto;
flex: 1;
}
.sticker-picker-item {
border: none;
background: none;
padding: 4px;
cursor: pointer;
border-radius: 8px;
transition: background 0.1s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.sticker-picker-item:hover {
background: var(--color-fill-2);
transform: scale(1.15);
}
/* 贴纸加载占位 */
.sticker-picker-loading {
grid-column: 1 / -1;
text-align: center;
padding: 24px;
color: var(--color-text-4);
font-size: 13px;
}
/* 移动端 */
@media (max-width: 640px) {
.sticker-picker-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
#### E2. 评论编辑器样式
新增 `.comment-editor` 相关样式,复用 `.article-editor-bar`、`.article-tool-btn` 等现有类名,仅做覆盖调整:
```css
/* 评论富文本编辑器 */
.comment-editor .article-editor-bar {
padding: 4px 8px;
}
.comment-editor .article-tool-btn {
width: 28px;
height: 28px;
}
.comment-editor .article-editor-content {
min-height: 80px;
max-height: 300px;
overflow-y: auto;
padding: 8px 12px;
font-size: 14px;
}
.comment-editor .article-editor-status {
padding: 4px 10px;
}
/* 评论内贴纸图片 */
.comment-sticker {
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
margin: 0 2px;
}
```
---
## 四、文件变更清单
### 新增文件
| 文件路径 | 用途 |
|----------|------|
| `frontend/src/data/stickers/hot.ts` | 热门贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/j13.ts` | 姜十三吉祥物贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/text.ts` | 文字气泡贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/index.ts` | 贴纸类型定义 + 统一导出 |
| `frontend/src/components/emoji/StickerSvg.tsx` | SVG 贴纸渲染组件 |
| `frontend/src/components/emoji/StickerPicker.tsx` | 贴纸选择器(懒加载) |
| `frontend/src/components/CommentEditor.tsx` | 评论富文本编辑器(精简 Tiptap) |
### 修改文件
| 文件 | 变更 |
|------|------|
| [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) | textarea → CommentEditor,移除 EmojiPicker,content 改为 HTML |
| [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) | 编辑模式 textarea → CommentEditor |
| [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) | 使用 `renderCommentContent()` 替代 `highlightMentions()` |
| [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) | 新增 `renderCommentContent()` + `processMentionsInHtml()` |
| [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) | 新增 `.sticker-picker*` 和 `.comment-editor*` 样式 |
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176) | Create() 增加 `SanitizePostHTML` |
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376) | Update() 增加 `SanitizePostHTML` |
### 删除文件
| 文件 | 原因 |
|------|------|
| [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) | Unicode Emoji 数据不再需要 |
| [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) | 被 StickerPicker 替代 |
---
## 五、假设与决策
### 5.1 编辑器决策
- **不直接复用 ArticleEditor 组件**,而是创建独立的 CommentEditor,原因:
- ArticleEditor 900 行,含全屏/Markdown/门控区块等评论不需要的功能
- 复用 ArticleEditor 的 Tiptap 扩展组件(ArticleCodeBlock、ArticleImage、TabIndent 等),避免代码重复
- CommentEditor 更轻量,每个评论实例加载更快
- **@ 提及**:暂不在编辑器中实现自动补全(需要 Tiptap Mention 扩展或自定义扩展),用户手动输入 `@username`,渲染时由 `processMentionsInHtml` 高亮。后续可增强为 Tiptap Mention 扩展。
### 5.2 贴纸存储格式
- 贴纸以 `data:image/svg+xml` URI 内联在 `<img src="...">` 中
- 每个 SVG 约 300-800 字节,单条评论即使 10 个贴纸也仅 ~8KB
- 后端 `SanitizePostHTML` 白名单已允许 `<img src="...">`,无需额外修改
- 旧评论(纯文本)不受影响,`renderCommentContent` 自动检测并兼容
### 5.3 懒加载策略
- 贴纸数据按分类拆分为 3 个 chunk(hot/j13/text),`React.lazy()` 动态导入
- 首次打开选择器只加载"热门"chunk(8 个贴纸),切换分类时才加载其他 chunk
- 每个 chunk 约 5-10KB,加载延迟 < 100ms
### 5.4 向后兼容
- 旧评论为纯文本,新 `renderCommentContent()` 通过 `isHtmlContent()` 检测自动走旧路径(escapeWithBreaks + 正则高亮)
- 新评论为 HTML,走 DOMPurify 净化 + DOM 遍历高亮路径
- 后端 `SanitizePostHTML` 对纯文本也安全(bluemonday 会保留纯文本,仅过滤危险标签)
---
## 六、实施顺序
1. **Part A**:贴纸数据 + 组件(stickers/*.ts → StickerSvg → StickerPicker)
2. **Part D**:后端评论 HTML 净化(comment.go Create/Update 加 SanitizePostHTML)
3. **Part B**:CommentEditor 组件 → CommentBox 集成 → CommentThreadList 编辑模式
4. **Part C**:content.ts 渲染函数 → CommentContent 更新
5. **Part E**:CSS 样式
6. 删除旧 EmojiPicker / emojis.ts
7. 验证测试
---
## 七、验证步骤
1. **贴纸系统验证**:
- 点击贴纸按钮,选择器弹出,3 个分类 Tab 可切换
- 切换分类时加载对应贴纸(Network 面板确认懒加载)
- 点击贴纸后编辑器中出现对应 SVG 图
2. **评论编辑器验证**:
- 评论框支持加粗/斜体/下划线/删除线/标题/引用/列表/代码块/链接/图片/贴纸
- 代码块支持语法高亮和折叠
- 图片可上传并插入
- 无全屏/Markdown/表格/门控区块按钮
3. **评论渲染验证**:
- 新评论 HTML 正确渲染(格式化、代码块、图片、贴纸)
- `@username` 在 HTML 评论中正确高亮为可点击链接
- 旧评论(纯文本)仍正常渲染
- 贴纸图片在评论中正确显示
4. **后端验证**:
- 发送含 `<script>alert(1)</script>` 的评论,后端净化后移除 script 标签
- 发送正常 HTML 评论,后端存储完整 HTML
- 旧纯文本评论正常存储和渲染
5. **编辑模式验证**:
- 编辑已有评论时,CommentEditor 正确加载 HTML 内容
- 保存后评论更新正确

View File

@@ -1,138 +0,0 @@
# 修复主页右侧栏评论点击定位问题
## 问题描述
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`floor > 0`),页面跳转后没有定位到该评论上。
## 根因分析
### 问题 1:`jumpToFloor` 使用 `scrollIntoView` 而非 `pageRef.scrollTo`
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L235-L242)
当前 `jumpToFloor` 函数使用 `el.scrollIntoView()` 来滚动:
```typescript
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// ...
}, []);
```
但是页面使用了自定义滚动容器 `pageRef`(类名为 `.post-detail-page`),且 `useGlobalWheelScroll` 钩子拦截了滚轮事件,将其转换为对 `pageRef.scrollTop` 的直接操作。这导致原生 `scrollIntoView` 可能无法正确触发滚动。
对比 `jumpToHeadingHash` 函数(第 244-262 行),它正确地使用了 `pageRef.scrollTo()`:
```typescript
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
// ...
const root = pageRef.current;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior });
}
// ...
}, []);
```
### 问题 2:`#floor-N` 定位缺少重试机制
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L264-L273)
当前 `#floor-N` 定位只有一次 80ms 延迟,没有重试机制:
```typescript
useEffect(() => {
if (loading || !post) return;
const m = location.hash.match(/^#floor-(\d+)$/);
if (!m) return;
const floor = Number(m[1]);
if (!floor) return;
const t = window.setTimeout(() => jumpToFloor(floor), 80);
return () => clearTimeout(t);
}, [loading, post, comments, location.hash, jumpToFloor]);
```
对比 `#heading-N` 定位(第 275-299 行),它有完善的重试机制(最多 30 次,每次 50ms)。
## 修改方案
### 修改 1:修改 `jumpToFloor` 函数使用 `pageRef.scrollTo`
将 `jumpToFloor` 函数改为使用与 `jumpToHeadingHash` 相同的滚动方式:
```typescript
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return false;
const root = pageRef.current;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
return true;
}, []);
```
### 修改 2:为 `#floor-N` 定位添加重试机制
将 `#floor-N` 定位的 `useEffect` 改为类似 `#heading-N` 的重试机制:
```typescript
useEffect(() => {
if (loading || !post) return;
const m = location.hash.match(/^#floor-(\d+)$/);
if (!m) return;
const floor = Number(m[1]);
if (!floor) return;
let cancelled = false;
let attempts = 0;
let timer = 0;
const tryJump = () => {
if (cancelled) return;
if (jumpToFloor(floor)) return;
attempts += 1;
if (attempts < 30) {
timer = window.setTimeout(tryJump, 50);
}
};
timer = window.setTimeout(tryJump, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [loading, post, comments, location.hash, jumpToFloor]);
```
## 涉及文件
- `frontend/src/pages/PostDetailPage.tsx`:修改 `jumpToFloor` 函数和 `#floor-N` 定位的 `useEffect`
## 风险评估
- **低风险**:修改仅限于评论定位逻辑,不影响其他功能
- **需测试**:需要验证页面加载时评论定位是否正常工作,以及页面内部导航时是否正常
## 测试步骤
1. 进入主页,点击右侧栏的一个非第一条评论
2. 验证页面跳转到帖子详情后,是否正确定位到目标评论
3. 验证评论高亮效果是否正常显示
4. 测试第一条评论(floor=0)的定位是否仍然正常工作
5. 在帖子详情页直接加载带 hash 的 URL(如 `/post/123#floor-5`),验证定位效果

View File

@@ -1,117 +0,0 @@
# 修复:再次进入帖子时滚动位置异常保留
## 问题
浏览帖子滑到下方 → 返回主页 → 再次进入同一帖子,滚动条停在之前阅读的位置,而非顶部。
## 根因
项目未设置 `history.scrollRestoration`(默认 `'auto'`)。浏览器在导航时会对**内部滚动容器**(`overflow: auto` 的 `.page-wrap`)执行滚动位置恢复。虽然 PostDetailPage 重新挂载后 `.page-wrap` 是新 DOM 元素,浏览器仍会在 paint 前将其 `scrollTop` 恢复到上次记录的值。
项目没有 ScrollToTop 机制来覆盖此行为。
## 与刷新恢复的冲突
之前实现的 `useScrollRestoration`(MainLayout mount-only)负责刷新场景的位置恢复。本修复必须与之共存:
| 场景 | 期望行为 | 处理者 |
|---|---|---|
| 刷新帖子页 | 恢复到上次位置 | `useScrollRestoration`(sessionStorage + rAF) |
| SPA 导航进入帖子 | 从顶部开始 | 本修复(重置 scrollTop=0) |
| `/post/123` → `/post/456` | 从顶部开始 | 本修复(重置 scrollTop=0) |
**区分依据**:`pagehide` 时 `saveScrollPositions` 将当前 URL 的滚动记录存入 sessionStorage。刷新后该记录存在;SPA 导航进入时该记录不存在(从未为此 URL 保存过,或已被 `restoreScrollPositions` 消费清除)。因此 PostDetailPage 渲染 `.page-wrap` 时检查 sessionStorage 是否有当前 URL 的记录即可区分两种场景。
**时序保证**:React 的 effect 执行顺序是子组件先于父组件。PostDetailPage 的 `useLayoutEffect` 在 MainLayout 的 `useEffect`(含 `useScrollRestoration`)之前执行。此时 sessionStorage 记录尚未被消费,`hasPendingScrollRestore()` 返回准确值。
## 改动计划
### 1. `frontend/src/utils/scrollRestore.ts` — 新增 `hasPendingScrollRestore`
在现有文件中新增导出函数:
```ts
/** 检查 sessionStorage 中是否有指定 URL 的待恢复滚动记录(不消费/不删除) */
export function hasPendingScrollRestore(url: string = getCurrentUrl()): boolean {
try {
const raw = sessionStorage.getItem(storageKey(url));
if (!raw) return false;
const entry = JSON.parse(raw) as SavedPositions;
return !!entry?.containers && Object.keys(entry.containers).length > 0;
} catch {
return false;
}
}
```
纯读取,不删除记录(`restoreScrollPositions` 的 rAF 循环仍需要它来恢复)。
### 2. 新建 `frontend/src/hooks/useScrollToTopOnMount.ts`
通用 hook,在滚动容器渲染就绪后重置到顶部(刷新场景跳过):
```ts
import { useLayoutEffect, useRef, type RefObject } from 'react';
import { hasPendingScrollRestore } from '../utils/scrollRestore';
/**
* 滚动容器渲染就绪后重置到顶部。
* - 首次就绪:刷新场景(sessionStorage 有记录)跳过,让 useScrollRestoration 恢复;
* SPA 导航(无记录)重置到顶部。
* - deps 变化(如 postId 变化):始终重置到顶部。
* - 同 deps 的 ready 状态变化(如评论刷新导致 loading 短暂为 true):不处理,避免误重置。
*/
export function useScrollToTopOnMount(
scrollRef: RefObject<HTMLElement | null>,
deps: React.DependencyList,
ready: boolean,
): void {
const lastKeyRef = useRef<string | null>(null);
const key = JSON.stringify(deps);
useLayoutEffect(() => {
if (!ready || !scrollRef.current) return;
if (lastKeyRef.current === null) {
lastKeyRef.current = key;
if (!hasPendingScrollRestore()) {
scrollRef.current.scrollTop = 0;
}
return;
}
if (lastKeyRef.current !== key) {
lastKeyRef.current = key;
scrollRef.current.scrollTop = 0;
}
}, [key, ready]); // ready 变化时重新检查
}
```
### 3. `frontend/src/pages/PostDetailPage.tsx` — 调用 hook
在 `pageRef` 定义之后(L108 之后)、`useGlobalWheelScroll` 调用处附近添加:
```ts
import { useScrollToTopOnMount } from '../hooks/useScrollToTopOnMount';
// ...
useScrollToTopOnMount(pageRef, [postId], !loading && !!post);
```
- `deps: [postId]` — `/post/123` → `/post/456` 时重置
- `ready: !loading && !!post` — `.page-wrap` 仅在此时渲染(L446 loading return、L447 !post return),pageRef.current 才有效
### 不需要改动的部分
- **其他页面**(FavoritesPage、MessagesPage、ProfilePage 等):用户仅报告了帖子页问题。这些页面内容较短,滚动保留不明显。如后续报告可复用此 hook。
- **MainLayout / AdminLayout**:`useScrollRestoration` 不变。
- **`history.scrollRestoration`**:不设为 `'manual'`,避免影响 back/forward 时其他页面的浏览器原生恢复。
## 验证步骤
1. **SPA 导航进入帖子**:从首页点击帖子 → 滑到下方 → 返回首页 → 再次点击同一帖子 → 应从顶部开始。
2. **帖子间切换**:`/post/123` 滑到下方 → 直接导航到 `/post/456` → 应从顶部开始。
3. **刷新保持位置**:在帖子页滑到下方 → F5 刷新 → 应恢复到原位置(`useScrollRestoration` 生效,hook 跳过)。
4. **硬刷新保持位置**:同上但用 Ctrl+F5 → 应恢复到原位置。
5. **首次访问**:从未访问过的帖子 → 应从顶部开始(sessionStorage 无记录)。
6. **诊断无错误**:TypeScript 编译通过。

View File

@@ -1,55 +0,0 @@
# 修复生产环境表情图片无法显示 Implementation Plan
## Repository Research
### 问题分析
表情图片在生产部署后无法加载,原因是 **Go 后端缺少 `/stickers/*` 路由的静态文件服务注册**。
当前架构:
1. 前端 emoji 数据中的 URL 为 `/stickers/{platform}/{file}.avif`(在 [emojiData.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/data/stickers/emojiData.ts) 中通过 `toLocalUrl()` 生成)
2. Vite 构建时将 `frontend/public/stickers/` 原样复制到 `embed_static/static/spa/stickers/`
3. Go 的 `//go:embed static/*` 会将 `embed_static/static/` 下所有文件(包括 stickers)打包进二进制
4. 但 [embed.go](file:///c:/Users/freefire/Documents/jiang13-forum/embed_static/embed.go) 的 `SetupEmbed()` **只注册了 `/assets/*filepath` 路由**(用于 JS/CSS chunk),没有注册 `/stickers/*filepath`
5. 同时 `IsSPARoute()` 函数也没有排除 `/stickers` 前缀,导致请求被 SPA NoRoute fallback 处理,返回 index.html 而非图片
开发模式之所以正常,是因为 Vite dev server 自动托管了 `public/stickers/` 下的静态文件。
### 当前代码状态
- `embed.go` 第 13 行: `//go:embed static/*` — 正确嵌入了所有静态资源(含 stickers)
- `embed.go` 第 33-43 行: `SetupEmbed()` — 只处理了 `static/spa/assets`,缺少 stickers 路由
- `embed.go` 第 63-77 行: `IsSPARoute()` — 缺少 `/stickers` 前缀排除
## Files and Modules
- `embed_static/embed.go`: 新增 `/stickers/*filepath` 文件服务路由;在 `IsSPARoute` 中排除 `/stickers` 前缀
## Implementation Steps
1. **在 `SetupEmbed` 中注册 `/stickers` 路由**
- 参照现有 `/assets` 路由的模式,添加对 `static/spa/stickers` 子目录的文件服务
- 使用 `fs.Sub(staticFS, "static/spa/stickers")` 获取 stickers 子文件系统
- 注册 `GET /stickers/*filepath` 路由,直接透传,不设置长期缓存(表情可能更新)
2. **在 `IsSPARoute` 中排除 `/stickers` 前缀**
- 在现有 `strings.HasPrefix(path, "/assets")` 附近添加 `strings.HasPrefix(path, "/stickers")`
- 确保表情请求不会被 SPA fallback 拦截
## Dependencies and Considerations
- stickers 目录下存储的是 `.avif` 图片文件,不需要设置特殊 MIME type(http.FileServer 会根据扩展名自动识别)
- stickers 资源不含哈希指纹,不应设置 `immutable` 缓存头(与 `/assets` 不同),但可以设置短过期缓存
- 需确保 `fs.Sub` 路径与实际构建输出路径一致:`embed_static/static/spa/stickers/`
## Validation
- 重新构建前端:`cd frontend && npm run build`
- 重新构建 Go 二进制:`go build -o jiang13-linux-amd64`
- 部署后访问 `/stickers/tieba/tb_01.avif` 应能直接返回图片内容(HTTP 200)
- 在评论框中打开表情选择器,所有平台的表情应正常显示
## Risks
- **风险**: 如果未来在 `public/` 下新增其他静态资源目录(如 `avatars/`、`flags/` 等),需要同样在 `embed.go` 中注册对应路由
- **应对**: 可考虑后续重构为自动扫描 `public/` 下所有子目录并自动注册路由,或改为统一的 SPA 静态文件服务方案

View File

@@ -0,0 +1,36 @@
---
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
alwaysApply: true
---
# 编译与构建脚本
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
## 运行编译(优先用封装命令,不要猜命令)
| 平台 | 命令 |
|------|------|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
| Linux / macOS | `make` 或 `make <target>` |
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
## 修改构建脚本时的约定
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
## 新增 target 检查清单
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
- [ ] README「快速开始」如需对外暴露再更新

View File

@@ -1,8 +0,0 @@
{
"hash": "de7e4cff",
"configHash": "9a7296da",
"lockfileHash": "e3b0c442",
"browserHash": "8c168d3c",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -1,15 +1,20 @@
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像 # 姜十三论坛 — 多阶段构建:Node 前端(Vike SSR)+ Go API,双服务共用镜像
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile # 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
# #
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理 # 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
# 海外或已配置 Docker registry-mirrors 时可传空前缀: # 海外或已配置 Docker registry-mirrors 时可传空前缀:
# docker build --build-arg IMAGE_PREFIX= ... # docker build --build-arg IMAGE_PREFIX= ...
#
# 运行形态(docker compose 双容器,同一镜像两种角色):
# api —— Go 二进制,纯 API(:3001 内部,/data 数据卷)
# web —— Node 渲染服务器(对外 :3000:SSR + 静态资源 + API 反代)
# 镜像默认入口为 api 角色;web 角色在 compose 中以 node 用户覆盖 entrypoint。
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/ ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev ARG VERSION=dev
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)──────────────────── # ── Stage 1: 前端构建(Vike → dist/client 静态资源 + dist/server SSR 入口)──
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend FROM ${IMAGE_PREFIX}node:24-bookworm-slim AS frontend
WORKDIR /src/frontend WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm config set registry https://registry.npmmirror.com \ RUN npm config set registry https://registry.npmmirror.com \
@@ -17,7 +22,7 @@ RUN npm config set registry https://registry.npmmirror.com \
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)──────────────────────── # ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0;仅 API,无内嵌前端)─────
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
ARG VERSION ARG VERSION
ENV GOPROXY=https://goproxy.cn,direct ENV GOPROXY=https://goproxy.cn,direct
@@ -25,31 +30,31 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \ -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/jiang13 ./cmd/jiang13 -o /out/jiang13 ./cmd/jiang13
# ── Stage 3: 运行镜像 ─────────────────────────────────────────────────────── # ── Stage 3: 运行镜像(Node 运行时承载 SSR 渲染与静态资源,附带 Go API 二进制)
FROM ${IMAGE_PREFIX}alpine:3.21 FROM ${IMAGE_PREFIX}node:24-bookworm-slim
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ RUN apt-get update \
&& apk add --no-cache ca-certificates tzdata wget su-exec \ && apt-get install -y --no-install-recommends gosu ca-certificates tzdata \
&& adduser -D -u 1000 -h /app jiang13 && rm -rf /var/lib/apt/lists/*
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai \
NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /out/jiang13 /app/jiang13 COPY --from=builder /out/jiang13 /app/jiang13
COPY --from=frontend /src/frontend /app/frontend
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chown jiang13:jiang13 /app/jiang13 \ RUN chmod +x /app/jiang13 /docker-entrypoint.sh
&& chmod +x /app/jiang13 /docker-entrypoint.sh
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13 # 以 root 启动,entrypoint 修正 /data 卷权限后 gosu 降权为 uid 1000(node 用户)
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 3000 EXPOSE 3000 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1 CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/docker-entrypoint.sh"] ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"] CMD ["--work-path", "/app", "--data", "/data", "--port", "3001"]

View File

@@ -5,46 +5,47 @@ APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13 MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist BUILD_DIR := dist
DEV_DATA_DIR := dist/data DEV_DATA_DIR := dist/data
VERSION := 1.0.0 VERSION := 1.1.8
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down .PHONY: all build web-build build-windows build-linux build-darwin build-all clean run serve dev tidy help docker compose-up compose-down
all: build all: build
frontend-build: ## 前端构建(Vike:产出 frontend/dist/client 静态资源 + dist/server SSR 入口)
cd frontend && npm install && npm run build web-build:
cd frontend && (test -d node_modules || npm install) && npm run build
## 编译当前平台二进制(纯 Go SQLite,无需 CGO) ## 编译当前平台二进制(纯 Go SQLite,无需 CGO;Go 仅含 API,前端由 Node 渲染)
build: frontend-build build:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64(先打包前端再 embed) ## Windows amd64
build-windows: frontend-build build-windows:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64(先打包前端再 embed) ## Linux amd64
build-linux: frontend-build build-linux:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64" @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)(先打包前端再 embed) ## macOS arm64 (Apple Silicon)
build-darwin: frontend-build build-darwin:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64" @echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译(frontend-build 只跑一次) ## 跨平台全量编译
build-all: frontend-build build-all:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@@ -56,12 +57,19 @@ build-all: frontend-build
tidy: tidy:
$(GO) mod tidy $(GO) mod tidy
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致) ## 本地运行(仅 Go API :3000,无前端页面;数据目录与 dist 二进制一致)
run: run:
@mkdir -p $(DEV_DATA_DIR) @mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR) $(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致) ## 生产模式本地运行(Node :3000 对外 SSR + Go API :3001)
serve: build web-build
@mkdir -p $(DEV_DATA_DIR)
@trap 'kill 0' INT; \
$(BUILD_DIR)/$(APP_NAME) --data $(DEV_DATA_DIR) --port 3001 & \
cd frontend && PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
## 前端热更新开发(Vike dev :5173 + Go API :3000,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
dev: dev:
@echo "前端热更新: http://localhost:5173" @echo "前端热更新: http://localhost:5173"
@echo "后端 API : http://localhost:3000" @echo "后端 API : http://localhost:3000"
@@ -79,7 +87,7 @@ clean:
docker: docker:
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest . docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
## Docker Compose 启动 ## Docker Compose 启动(web :3000 + api :3001 双容器)
compose-up: compose-up:
docker compose up -d --build docker compose up -d --build
@@ -89,12 +97,14 @@ compose-down:
help: help:
@echo "姜十三论坛编译命令:" @echo "姜十三论坛编译命令:"
@echo " make build - 编译当前平台" @echo " make build - 编译 Go API 二进制(当前平台)"
@echo " make web-build - 构建前端(Vike:client + server 产物)"
@echo " make serve - 生产模式本地运行(Node :3000 + Go API :3001)"
@echo " make build-windows - 编译 Windows" @echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux" @echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS" @echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台" @echo " make build-all - 编译全部平台"
@echo " make run - 仅启动后端(:3000)" @echo " make run - 仅启动 Go API(:3000)"
@echo " make dev - 前端热更新开发(:5173 + :3000)" @echo " make dev - 前端热更新开发(:5173 + :3000)"
@echo " make docker - 构建 Docker 镜像" @echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动" @echo " make compose-up - Docker Compose 启动"

153
README.md
View File

@@ -13,13 +13,14 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum) [![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
[![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![React](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json)
[![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
[在线演示](https://bbs.iioio.com/) · [在线演示](https://bbs.iioio.com/) ·
[快速开始](#-快速开始) · [快速开始](#-快速开始) ·
[界面预览](#-界面预览) · [界面预览](#-界面预览) ·
[功能亮点](#-功能亮点) · [功能亮点](#-功能亮点) ·
[更新日志](docs/site-posts/changelog.md) ·
[路线图](ROADMAP.md) · [路线图](ROADMAP.md) ·
[参与贡献](CONTRIBUTING.md) [参与贡献](CONTRIBUTING.md)
@@ -113,11 +114,12 @@
### 部署体验 ### 部署体验
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源 - **双服务架构** — Node(:3000 对外:SSR + 静态资源 + API 反代)+ Go(:3001 内部:纯 API),职责单一、可独立扩缩
- **服务端渲染** — 首页 / 板块 / 帖子详情由 Vike SSR 真渲染,SEO 友好、首屏直出;其余页面 SPA 接管
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 - **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **跨平台** — Windows / Linux / macOS 一键编译 - **跨平台** — Windows / Linux / macOS 一键编译 Go API
- **系统服务** — 内置 Linux systemd / Windows Service 注册 - **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化 - **Docker 双容器** — 同一镜像两种角色,`docker compose up` 一键起
--- ---
@@ -125,7 +127,7 @@
### 1. 编译 ### 1. 编译
**Windows(推荐):** Go API 二进制(不含前端,Windows):
```bat ```bat
build.bat build.bat
@@ -141,9 +143,21 @@ make build
**手动分步(全平台):** **手动分步(全平台):**
```bash
go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
```
前端构建(Vike,产出 `frontend/dist/client` + `dist/server`):
```bash ```bash
cd frontend && npm install && npm run build cd frontend && npm install && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 ```
本地生产模式一键起双服务(Node :3000 对外 + Go API :3001):
```bash
make serve # Linux / macOS
build.bat -Target serve # Windows
``` ```
跨平台编译: 跨平台编译:
@@ -172,32 +186,45 @@ make compose-up
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。 浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
版本变更与升级说明见 **[更新日志](docs/site-posts/changelog.md)**。Docker 与 Windows / Linux 单文件共用同一版本号。
- Docker 标签:[hub.docker.com/r/hangzhang714128/jiang13-forum/tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags)
- 预编译包:[Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases)
**拉取已构建镜像(Docker Hub):** **拉取已构建镜像(Docker Hub):**
同一镜像两种角色,建议直接用 `docker-compose.yml`(已含完整配置):
```bash ```bash
docker pull hangzhang714128/jiang13-forum:latest docker pull hangzhang714128/jiang13-forum:latest
docker run -d --name jiang13 \ docker compose up -d
-p 3000:3000 \
-v jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
``` ```
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。 Compose 会启动两个容器:
| 容器 | 角色 | 端口 | 说明 |
|------|------|------|------|
| `web` | Node 渲染服务器 | `3000` 对外 | Vike SSR + 静态资源 + API 反代(`GO_API_URL=http://api:3001`) |
| `api` | Go API | `3001` 内部 | 纯 API 服务,持有 `/data` 数据卷 |
**数据持久化:** `api` 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`,适配 1Panel 等面板挂载的目录。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录` **若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
**可选环境变量(容器编排):** **可选环境变量(容器编排):**
| 变量 | 说明 | | 变量 | 容器 | 说明 |
|------|------| |------|------|------|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) | | `GO_API_URL` | web | Go API 地址(默认 `http://127.0.0.1:3001`,Compose 中为 `http://api:3001`) |
| `JIANG13_DATA` | 数据目录(默认 `/data`) | | `PORT` | web | Node 渲染服务器监听端口(默认 `3000`) |
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) | | `JIANG13_HTTP_PORT` | api | HTTP 端口(默认 `3000`,Compose 中为 `3001`) |
| `JIANG13_CONFIG` | 配置文件路径 | | `JIANG13_DATA` | api | 数据目录(默认 `/data`) |
| `JIANG13_WORK_PATH` | 工作目录 | | `JIANG13_JWT_SECRET` | api | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
| `JIANG13_CONFIG` | api | 配置文件路径 |
| `JIANG13_WORK_PATH` | api | 工作目录 |
| `JIANG13_COMMUNITY_HUB` | api | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。 **健康检查:** Go API `GET /health`、Node 渲染服务器 `GET /healthz`,均返回 200,供 Docker / 负载均衡探活。
**发布镜像到 Docker Hub(手动):** **发布镜像到 Docker Hub(手动):**
@@ -205,15 +232,15 @@ docker run -d --name jiang13 \
docker login docker login
.\build.bat -Target docker # Windows .\build.bat -Target docker # Windows
# make docker # Linux/macOS # make docker # Linux/macOS
docker push hangzhang714128/jiang13-forum:1.0.0 docker push hangzhang714128/jiang13-forum:1.1.7
docker push hangzhang714128/jiang13-forum:latest docker push hangzhang714128/jiang13-forum:latest
``` ```
或直接构建: 或直接构建:
```bash ```bash
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest . docker build --build-arg VERSION=1.1.7 -t hangzhang714128/jiang13-forum:1.1.7 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.0.0 docker push hangzhang714128/jiang13-forum:1.1.7
docker push hangzhang714128/jiang13-forum:latest docker push hangzhang714128/jiang13-forum:latest
``` ```
@@ -231,29 +258,42 @@ docker push hangzhang714128/jiang13-forum:latest
**1Panel 部署提示:** **1Panel 部署提示:**
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest` 1. 用 Compose 编排(`docker-compose.yml`)或手动创建两个容器:`api`(Go,内部 `3001`,挂载 `/data`)与 `web`(Node,映射 `3000:3000`)
2. 端口映射 `3000:3000` 2. `web` 容器环境变量 `GO_API_URL` 指向 `api` 容器(如 `http://api:3001`)
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限) 3. 数据卷只需挂在 `api` 容器的 `/data`(镜像会自动修正目录权限)
4. 首次访问 `http://服务器IP:3000/register` 注册管理员 4. 首次访问 `http://服务器IP:3000/register` 注册管理员
### 3. 直接启动(二进制) ### 3. 直接启动(二进制)
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`): Go 二进制仅提供 API,完整站点需同时运行 Node 渲染服务器(构建前端后 `frontend/server/index.mjs`)。最简单的方式是用上面的 Docker Compose,或本地 `make serve` / `build.bat -Target serve` 一键起双进程。
如需手动部署二进制,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件:
| 文件 | 平台 |
|------|------|
| `jiang13-*-windows-amd64.exe` | Windows x64 |
| `jiang13-*-linux-amd64` | Linux x64 |
当前 **1.1.7:** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
两个进程一起跑(Go API :3001 + Node :3000 对外):
```bash ```bash
# Windows # 1) 启动 Go API
.\dist\jiang13.exe ./jiang13 --port 3001 --data ./data
# Linux / macOS # 2) 另开终端,构建并启动 Node 渲染服务器
./dist/jiang13 cd frontend && npm install && npm run build
PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
``` ```
也可先复制示例配置再改端口 / 数据目录: Windows:
```bash ```powershell
cp app.ini.example /opt/jiang13/app.ini .\jiang13.exe --port 3001 --data .\data
# 编辑 app.ini 后: # 另开 PowerShell:
./jiang13 cd frontend; npm install; npm run build
$env:PORT = '3000'; $env:GO_API_URL = 'http://127.0.0.1:3001'; npm start
``` ```
### 4. 首次使用 ### 4. 首次使用
@@ -292,7 +332,7 @@ JWT_SECRET =
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | | `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH` **环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
### 5. 注册为系统服务(可选) ### 5. 注册为系统服务(可选)
@@ -325,16 +365,17 @@ C:\jiang13\jiang13.exe --service start
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite | | **后端** | Go 1.26 · Gin · GORM · SQLite(纯 API 服务) |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | **前端** | React 19 · Vike SSR · react-router · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 | | **构建** | Vite 8(Rolldown)双构建:`dist/client` 静态资源 + `dist/server` SSR 入口 |
| **渲染** | Node 24 · Express + Vike `renderPage`(:3000 对外,API 反代至 Go :3001) |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider | | **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
--- ---
## 前端开发 ## 前端开发
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换): 日常改前端不需要重新完整构建,Vike dev 支持秒级热更新(HMR,热模块替换):
```bat ```bat
build.bat -Target dev build.bat -Target dev
@@ -344,16 +385,17 @@ build.bat -Target dev
make dev make dev
``` ```
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。 浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。首页 / 板块 / 帖子详情在 dev 下同样走 SSR(`vike dev`),与生产行为一致;其余页面为 SPA 壳。
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。 开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
**何时需要完整构建:** **何时需要完整构建:**
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build` - 修改 Go 代码 → `build.bat` / `make build`(仅编译 Go API)
- 验证生产 SSR / 发布部署 → `make serve`(构建前端 + 双进程起本地产)
- 更新 README 界面截图 → 见下方「更新截图」 - 更新 README 界面截图 → 见下方「更新截图」
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。 > 开发时请访问 `:5173`;`:3000` 只有在生产模式(Node 渲染服务器)下才有页面。
### 更新截图 ### 更新截图
@@ -383,17 +425,20 @@ jiang13-forum/
├── cmd/jiang13/ # 程序入口(含系统服务注册) ├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # app.ini 与命令行配置 ├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例 ├── app.ini.example # 配置文件示例
├── Dockerfile # 多阶段 Docker 构建 ├── Dockerfile # 多阶段构建(前端 + Go API 共用镜像,双容器运行)
├── docker-compose.yml # 单容器 Compose 部署 ├── docker-compose.yml # 双容器 Compose 部署(web :3000 + api :3001)
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限) ├── docker-entrypoint.sh # api 容器启动脚本(修正 /data 卷权限)
├── .dockerignore ├── .dockerignore
├── model/ # GORM 模型与数据库迁移 ├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑 ├── service/ # 业务逻辑
├── handler/ # HTTP 处理器(前台 + 后台) ├── handler/ # HTTP 处理器(API + /api/home-boot SSR 聚合端点)
├── middleware/ # JWT 鉴权等 ├── middleware/ # JWT 鉴权等
├── router/ # 路由注册 ├── router/ # 路由注册(仅 API)
├── embed_static/ # go:embed 内嵌的 SPA ├── frontend/
├── frontend/ # React 源码(Vite 构建) │ ├── pages/ # Vike 页面(feed=SSR 白名单 / spa=catch-all 壳 / _error)
│ ├── renderer/ # Vike 双端渲染入口(onRenderHtml / onRenderClient)
│ ├── server/ # Node 渲染服务器(express + renderPage + API 反代)
│ └── src/ # React 源码(含 src/ssr 服务端取数与作用域管理)
├── docs/screenshots/ # README 界面截图 ├── docs/screenshots/ # README 界面截图
├── ROADMAP.md # 路线图与已知问题 ├── ROADMAP.md # 路线图与已知问题
└── scripts/ # 开发辅助脚本(含截图) └── scripts/ # 开发辅助脚本(含截图)
@@ -405,13 +450,15 @@ jiang13-forum/
``` ```
data/ data/
├── jiang13.db # SQLite 主数据库 ├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── jiang13.log # 运行日志 ├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置 ├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成) ├── .jwt_secret # JWT 密钥(自动生成)
├── logs/access/ # 网站监控请求日志(按日 jsonl)
├── uploads/avatars/ # 用户头像 ├── uploads/avatars/ # 用户头像
├── uploads/posts/ # 帖子正文图片 ├── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的备份 └── jiang13_backup_*.db # 后台导出的主库备份
``` ```
--- ---

View File

@@ -13,3 +13,13 @@ DATA = data
[security] [security]
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库) ; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
JWT_SECRET = JWT_SECRET =
; ---------------------------------------------------------------------------
; 维护者选项(默认关闭;普通自托管无需开启)
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
; ---------------------------------------------------------------------------
; [community]
; HUB = false

View File

@@ -3,7 +3,7 @@
# .\build.ps1 -Target build-windows # .\build.ps1 -Target build-windows
param( param(
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] [ValidateSet('build', 'web-build', 'frontend', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'tidy', 'run', 'serve', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build' [string]$Target = 'build'
) )
@@ -12,7 +12,7 @@ $AppName = 'jiang13'
$MainPkg = './cmd/jiang13' $MainPkg = './cmd/jiang13'
$BuildDir = 'dist' $BuildDir = 'dist'
$DevDataDir = 'dist/data' $DevDataDir = 'dist/data'
$Version = '1.0.0' $Version = '1.1.8'
$RegistryImage = 'hangzhang714128/jiang13-forum' $RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version" $Ldlags = "-s -w -X main.version=$Version"
@@ -23,7 +23,7 @@ function Ensure-Dir($path) {
} }
function Build-Frontend { function Build-Frontend {
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan Write-Host '[frontend] npm run build (Vike: client + server)...' -ForegroundColor Cyan
Push-Location frontend Push-Location frontend
try { try {
if (-not (Test-Path node_modules)) { if (-not (Test-Path node_modules)) {
@@ -69,13 +69,14 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
switch ($Target) { switch ($Target) {
'help' { 'help' {
Write-Host '.\build.ps1 build current platform' Write-Host '.\build.ps1 build Go API binary (current platform)'
Write-Host '.\build.ps1 -Target frontend frontend only' Write-Host '.\build.ps1 -Target web-build build frontend (Vike client + server)'
Write-Host '.\build.ps1 -Target serve production run: Node :3000 + Go API :3001'
Write-Host '.\build.ps1 -Target build-windows' Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux' Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all' Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run backend only (port 3000)' Write-Host '.\build.ps1 -Target run Go API only (port 3000, no pages)'
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)' Write-Host '.\build.ps1 -Target dev Vike HMR :5173 + Go API :3000 (recommended for frontend dev)'
Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean' Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image' Write-Host '.\build.ps1 -Target docker build Docker image'
@@ -84,6 +85,7 @@ switch ($Target) {
Write-Host '' Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
} }
'web-build' { Build-Frontend }
'frontend' { Build-Frontend } 'frontend' { Build-Frontend }
'tidy' { go mod tidy } 'tidy' { go mod tidy }
'clean' { 'clean' {
@@ -94,11 +96,35 @@ switch ($Target) {
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir go run $MainPkg --data $DevDataDir
} }
'serve' {
Build-Frontend
Build-Go -OutFile $AppName
Ensure-Dir $DevDataDir
$root = (Get-Location).Path
$goExe = Join-Path $root "$BuildDir\$AppName.exe"
Write-Host '[serve] 正在新窗口启动 Go API (:3001)...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; & '$goExe' --data '$DevDataDir' --port 3001"
) | Out-Null
Start-Sleep -Seconds 2
Write-Host '[serve] 启动 Node 渲染服务器 (:3000)...' -ForegroundColor Cyan
Push-Location frontend
try {
$env:PORT = '3000'
$env:GO_API_URL = 'http://127.0.0.1:3001'
npm start
} finally {
Remove-Item Env:PORT -ErrorAction SilentlyContinue
Remove-Item Env:GO_API_URL -ErrorAction SilentlyContinue
Pop-Location
}
}
'dev' { 'dev' {
$root = (Get-Location).Path $root = (Get-Location).Path
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
Write-Host '' Write-Host ''
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green Write-Host '[dev] 前端开发 : http://localhost:5173 (Vike HMR)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
@@ -117,24 +143,18 @@ switch ($Target) {
} }
} }
'build' { 'build' {
Build-Frontend
Build-Go -OutFile $AppName Build-Go -OutFile $AppName
} }
'build-windows' { 'build-windows' {
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
} }
'build-linux' { 'build-linux' {
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
} }
'build-darwin' { 'build-darwin' {
Build-Frontend
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
} }
'build-all' { 'build-all' {
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'

View File

@@ -8,12 +8,14 @@ import (
"github.com/kardianos/service" "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
appsvc "git.iioio.com/freefire/jiang13-forum/service"
) )
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入 // version 由构建脚本通过 -ldflags "-X main.version=..." 注入
var version = "dev" var version = "dev"
func main() { func main() {
appsvc.SetAppVersion(version)
cfg, err := config.Parse() cfg, err := config.Parse()
if err != nil { if err != nil {
log.Fatalf("配置解析失败: %v", err) log.Fatalf("配置解析失败: %v", err)

View File

@@ -12,6 +12,7 @@ import (
"github.com/kardianos/service" "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
forumsvc "git.iioio.com/freefire/jiang13-forum/service"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/router" "git.iioio.com/freefire/jiang13-forum/router"
) )
@@ -78,6 +79,12 @@ func (p *program) setup() error {
if err := model.InitDB(cfg.DBPath()); err != nil { if err := model.InitDB(cfg.DBPath()); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err) return fmt.Errorf("数据库初始化失败: %w", err)
} }
if err := forumsvc.BackfillModerationNotifyRefs(); err != nil {
log.Printf("待审通知关联字段回填警告: %v", err)
}
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
return fmt.Errorf("监控库初始化失败: %w", err)
}
engine, err := router.Setup(cfg) engine, err := router.Setup(cfg)
if err != nil { if err != nil {
@@ -91,7 +98,11 @@ func (p *program) setup() error {
} }
log.Printf("姜十三论坛已启动: http://localhost%s", addr) log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) if cfg.DevMode {
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
} else {
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
}
log.Printf("工作目录: %s", cfg.WorkPath) log.Printf("工作目录: %s", cfg.WorkPath)
log.Printf("配置文件: %s", cfg.ConfigFile) log.Printf("配置文件: %s", cfg.ConfigFile)
log.Printf("数据目录: %s", cfg.DataDir) log.Printf("数据目录: %s", cfg.DataDir)

View File

@@ -0,0 +1,410 @@
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
//
// go run ./cmd/monitor-geo-check -db data/monitor.db
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
flag.Parse()
abs, err := filepath.Abs(*dbPath)
if err != nil {
fatalf("解析路径失败: %v", err)
}
dataDir := filepath.Dir(abs)
if *ipLookup == "" {
if st, err := os.Stat(abs); err != nil || st.IsDir() {
fatalf("数据库不存在: %s", abs)
}
}
fmt.Printf("数据库: %s\n", abs)
fmt.Printf("数据目录: %s\n\n", dataDir)
suite := service.NewGeoIPService(dataDir)
defer suite.Close()
printGeoFiles(dataDir, suite)
if ip := strings.TrimSpace(*ipLookup); ip != "" {
lookupIP(suite, ip)
fmt.Println()
if _, err := os.Stat(abs); err != nil {
return
}
}
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
fatalf("打开数据库失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
fatalf("获取连接失败: %v", err)
}
defer sqlDB.Close()
sqlDB.SetMaxOpenConns(1)
if !tableExists(db, "page_views") {
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
}
have := columnSet(db)
printColumnPresence(have)
since := time.Now().AddDate(0, 0, -30)
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
_ = db.Table("page_views").Count(&total).Error
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
fmt.Printf("总量: %d\n", total)
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
if have["country"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
fmt.Printf("有国家: %d\n", withCountry)
} else {
fmt.Println("有国家: (列缺失)")
}
if have["region"] || have["region_iso"] {
cond := "created_at >= ? AND is_bot = ?"
parts := []string{}
if have["region"] {
parts = append(parts, "region <> ''")
}
if have["region_iso"] {
parts = append(parts, "region_iso <> ''")
}
_ = db.Table("page_views").
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
Count(&withRegion).Error
fmt.Printf("有省字段: %d\n", withRegion)
} else {
fmt.Println("有省字段: (列缺失)")
}
if have["city"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
fmt.Printf("有城市: %d\n", withCity)
} else {
fmt.Println("有城市: (列缺失)")
}
if have["asn"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
fmt.Printf("有 ASN: %d\n", withASN)
} else {
fmt.Println("有 ASN: (列缺失)")
}
fmt.Println()
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
if !(have["region"] || have["region_iso"]) {
fmt.Println("(省列尚未迁移,跳过)")
} else {
type regionRow struct {
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
Count int64
}
parts := []string{}
if have["region"] {
parts = append(parts, "region <> ''")
}
if have["region_iso"] {
parts = append(parts, "region_iso <> ''")
}
selParts := []string{}
groupParts := []string{}
if have["country"] {
selParts = append(selParts, "country")
groupParts = append(groupParts, "country")
} else {
selParts = append(selParts, "'' as country")
}
if have["region"] {
selParts = append(selParts, "region")
groupParts = append(groupParts, "region")
} else {
selParts = append(selParts, "'' as region")
}
if have["region_iso"] {
selParts = append(selParts, "region_iso")
groupParts = append(groupParts, "region_iso")
} else {
selParts = append(selParts, "'' as region_iso")
}
selParts = append(selParts, "COUNT(*) as count")
var regions []regionRow
_ = db.Table("page_views").
Select(strings.Join(selParts, ", ")).
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
Group(strings.Join(groupParts, ", ")).
Order("count DESC").
Limit(20).
Scan(&regions).Error
if len(regions) == 0 {
fmt.Println("(空)")
} else {
for i, r := range regions {
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
}
}
}
fmt.Println()
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
printGeoReconcileSamples(db, have, since)
fmt.Println()
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
cols := []string{"id", "created_at", "path", "is_bot"}
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
if have[c] {
cols = append(cols, c)
}
}
type sample struct {
ID uint
CreatedAt time.Time
IP string
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
City string
ASN uint
ASOrg string `gorm:"column:as_org"`
Path string
IsBot bool
}
var samples []sample
_ = db.Table("page_views").
Select(strings.Join(cols, ", ")).
Where("created_at >= ?", since).
Order("id DESC").
Limit(10).
Scan(&samples).Error
if len(samples) == 0 {
fmt.Println("(无近 30 日 pageview)")
} else {
for _, s := range samples {
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
}
}
regionReady := have["region"] || have["region_iso"]
if !regionReady || withRegion == 0 {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
if !regionReady {
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
}
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
os.Exit(2)
}
}
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
type sample struct {
ID uint
CreatedAt time.Time
IP string
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
City string
ASN uint
ASOrg string `gorm:"column:as_org"`
Path string
}
selCols := []string{"id", "created_at", "path"}
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
if have[c] {
selCols = append(selCols, c)
}
}
sel := strings.Join(selCols, ", ")
printBlock := func(title string, n int64, rows []sample) {
fmt.Printf("%s: %d\n", title, n)
if len(rows) == 0 {
return
}
for _, s := range rows {
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
}
}
if have["city"] {
var n int64
_ = db.Table("page_views").
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("英文城市名(可能未中文化)", n, rows)
} else {
fmt.Println("英文城市名: (city 列缺失)")
}
if have["region"] || have["region_iso"] {
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
emptyParts := []string{}
if have["region"] {
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
}
if have["region_iso"] {
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
}
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
var n int64
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where(where, since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("有国家但省为空", n, rows)
} else {
fmt.Println("有国家但省为空: (省列缺失)")
}
if have["asn"] {
var n int64
_ = db.Table("page_views").
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("asn=0(无运营商)", n, rows)
} else {
fmt.Println("asn=0: (asn 列缺失)")
}
}
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
fmt.Println("=== Geo 数据文件 ===")
v4, v6, asn, country := suite.Paths()
for _, item := range []struct {
name string
path string
ok bool
}{
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
} {
st, err := os.Stat(item.path)
if err != nil {
fmt.Printf("%s: 不存在\n", item.name)
continue
}
loaded := "未加载"
if item.ok {
loaded = "已加载"
}
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
}
fmt.Println()
}
func lookupIP(suite *service.GeoIPService, ipStr string) {
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
info := suite.Lookup(ipStr)
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
return
}
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
if info.Region != "" || info.RegionISO != "" {
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
}
if info.City != "" {
fmt.Printf("城市: %s\n", info.City)
}
if info.ASN > 0 || info.ASOrg != "" {
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
}
}
func tableExists(db *gorm.DB, name string) bool {
var n int
err := db.Raw(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
).Scan(&n).Error
return err == nil && n > 0
}
func columnSet(db *gorm.DB) map[string]bool {
type colRow struct {
Name string `gorm:"column:name"`
}
var cols []colRow
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
have := map[string]bool{}
for _, c := range cols {
have[c.Name] = true
}
return have
}
func printColumnPresence(have map[string]bool) {
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
fmt.Println("=== page_views 列 ===")
missing := false
for _, n := range need {
if have[n] {
fmt.Printf("%s: 有\n", n)
} else {
fmt.Printf("%s: 缺失\n", n)
missing = true
}
}
fmt.Println()
if missing {
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
fmt.Fprintln(os.Stderr, "")
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

View File

@@ -32,6 +32,9 @@ type Config struct {
ServiceAction string ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源) // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool
} }
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录 // Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
@@ -108,6 +111,11 @@ func Parse() (*Config, error) {
jwtSecret = strings.TrimSpace(*jwtFlag) jwtSecret = strings.TrimSpace(*jwtFlag)
} }
communityHub := fileCfg.CommunityHub
if v := envBoolOrNil(envCommunityHub); v != nil {
communityHub = *v
}
cfg := &Config{ cfg := &Config{
WorkPath: workPath, WorkPath: workPath,
ConfigFile: configFile, ConfigFile: configFile,
@@ -117,6 +125,7 @@ func Parse() (*Config, error) {
LogFile: filepath.Join(absData, "jiang13.log"), LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action, ServiceAction: action,
DevMode: *devFlag, DevMode: *devFlag,
CommunityHub: communityHub,
} }
needDirs := action == "" || action == "install" needDirs := action == "" || action == "install"
@@ -184,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
filepath.Join(dataDir, "uploads", "avatars"), filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"), filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"), filepath.Join(dataDir, "uploads", "site"),
filepath.Join(dataDir, "logs", "access"),
} { } {
if err := os.MkdirAll(sub, 0755); err != nil { if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err) return fmt.Errorf("创建上传目录失败: %w", err)
@@ -218,11 +228,16 @@ func validServiceAction(action string) bool {
} }
} }
// DBPath 返回 SQLite 数据库文件路径 // DBPath 返回主库 SQLite 路径
func (c *Config) DBPath() string { func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db") return filepath.Join(c.DataDir, "jiang13.db")
} }
// MonitorDBPath 返回监控独立库路径(page_views)
func (c *Config) MonitorDBPath() string {
return filepath.Join(c.DataDir, "monitor.db")
}
// AvatarUploadDir 返回头像上传目录 // AvatarUploadDir 返回头像上传目录
func (c *Config) AvatarUploadDir() string { func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars") return filepath.Join(c.DataDir, "uploads", "avatars")

View File

@@ -8,11 +8,12 @@ import (
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认) // 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
const ( const (
envWorkPath = "JIANG13_WORK_PATH" envWorkPath = "JIANG13_WORK_PATH"
envConfig = "JIANG13_CONFIG" envConfig = "JIANG13_CONFIG"
envHTTPPort = "JIANG13_HTTP_PORT" envHTTPPort = "JIANG13_HTTP_PORT"
envData = "JIANG13_DATA" envData = "JIANG13_DATA"
envJWTSecret = "JIANG13_JWT_SECRET" envJWTSecret = "JIANG13_JWT_SECRET"
envCommunityHub = "JIANG13_COMMUNITY_HUB"
) )
func envOrDefault(key string) string { func envOrDefault(key string) string {
@@ -30,3 +31,21 @@ func envIntOrZero(key string) int {
} }
return n return n
} }
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
func envBoolOrNil(key string) *bool {
v := strings.ToLower(envOrDefault(key))
if v == "" {
return nil
}
switch v {
case "1", "true", "yes", "on":
t := true
return &t
case "0", "false", "no", "off":
f := false
return &f
default:
return nil
}
}

View File

@@ -18,9 +18,10 @@ const (
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径) // fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
type fileSettings struct { type fileSettings struct {
Port int Port int
DataRel string DataRel string
JWTSecret string JWTSecret string
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
} }
func defaultFileSettings() fileSettings { func defaultFileSettings() fileSettings {
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String()) out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
} }
if sec, err := cfg.GetSection("community"); err == nil {
if k := sec.Key("HUB"); k.String() != "" {
out.CommunityHub = k.MustBool(false)
}
}
return out, nil return out, nil
} }

View File

@@ -1,17 +1,17 @@
# 姜十三论坛 — Docker Compose 单服务部署 # 姜十三论坛 — Docker Compose 双服务部署
# 架构:web(Node :3000 对外:Vike SSR + 静态资源 + API 反代) → api(Go :3001 内部:纯 API + /data 数据卷)
# 启动:docker compose up -d --build # 启动:docker compose up -d --build
# 或:make compose-up / build.bat -Target compose-up # 或:make compose-up / build.bat -Target compose-up
services: services:
jiang13: api:
image: hangzhang714128/jiang13-forum:latest image: hangzhang714128/jiang13-forum:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VERSION: dev VERSION: dev
ports: command: ["--work-path", "/app", "--data", "/data", "--port", "3001"]
- "3000:3000"
volumes: volumes:
- jiang13-data:/data - jiang13-data:/data
# 可选:挂载自定义 app.ini(只读) # 可选:挂载自定义 app.ini(只读)
@@ -20,10 +20,34 @@ services:
TZ: Asia/Shanghai TZ: Asia/Shanghai
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret) # 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
# JIANG13_JWT_SECRET: your-secret-here # JIANG13_JWT_SECRET: your-secret-here
expose:
- "3001"
restart: unless-stopped restart: unless-stopped
stop_grace_period: 15s stop_grace_period: 15s
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
web:
image: hangzhang714128/jiang13-forum:latest
entrypoint: ["node", "server/index.mjs"]
working_dir: /app/frontend
user: "1000:1000"
ports:
- "3000:3000"
environment:
TZ: Asia/Shanghai
PORT: "3000"
GO_API_URL: http://api:3001
depends_on:
api:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@@ -1,15 +1,16 @@
#!/bin/sh #!/bin/sh
set -e set -e
# api 容器入口:默认运行 Go API(web 容器在 compose 中以 node 用户直接覆盖 entrypoint)。
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0), # 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。 # 而应用以 uid 1000(node 镜像内置用户)运行,需在启动时修正 /data 权限后降权。
DATA_DIR="/data" DATA_DIR="/data"
if [ "$(id -u)" = '0' ]; then if [ "$(id -u)" = '0' ]; then
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
chown -R jiang13:jiang13 "$DATA_DIR" chown -R 1000:1000 "$DATA_DIR"
exec su-exec jiang13 /app/jiang13 "$@" exec gosu 1000:1000 /app/jiang13 "$@"
fi fi
exec /app/jiang13 "$@" exec /app/jiang13 "$@"

105
docs/docker-data.md Normal file
View File

@@ -0,0 +1,105 @@
# Docker 数据持久化说明
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
## 挂载对应关系
| 宿主机 | 容器内 |
|--------|--------|
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
**示例:**
```bash
docker run -d --name jiang13 \
-p 3000:3000 \
-v /你的路径/jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
或在 `docker-compose.yml` 中:
```yaml
volumes:
- jiang13-data:/data
# 或绑定宿主机目录:
# - ./data:/data
```
## `/data` 目录结构
数据库与附件都在同一个 `/data` 挂载内:
```
/data/
├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── uploads/ # 附件根目录
│ ├── avatars/ # 用户头像
│ └── posts/ # 帖子图片等
├── logs/
│ └── access/ # 网站监控请求日志(按日 jsonl)
│ └── YYYY-MM-DD.jsonl
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
├── GeoLite2-Country.mmdb # 可选:国家兜底
├── .jwt_secret # JWT 密钥(自动生成)
├── filter_words.txt # 敏感词配置
└── jiang13.log # 运行日志(若启用)
```
| 用途 | 路径 |
|------|------|
| 主数据库 | `/data/jiang13.db` |
| 监控浏览量 | `/data/monitor.db` |
| 附件 | `/data/uploads/` |
| 请求日志(监控) | `/data/logs/access/` |
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
## 设计说明:为何只挂一个 `/data`
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
## 后续计划(可选增强,非必须)
按优先级,仅在有明确需求时做:
1. **文档与面板提示(优先)**
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
- 本页保持为权威说明,避免用户误以为漏了一个卷。
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
```yaml
volumes:
# 推荐:父目录一次挂载
- /ssd/jiang13:/data
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
- /ssd/jiang13:/data
- /hdd/jiang13-uploads:/data/uploads
```
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
3. **产品级:可配置上传根目录(中期)**
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
4. **不做的事**
- 不为「看起来像 WordPress」而强制双卷。
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。

79
docs/monitor.md Normal file
View File

@@ -0,0 +1,79 @@
# 网站监控设计说明
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
## 双通道数据
| 数据 | 存储 | 用途 |
|------|------|------|
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
### 文件行格式(JSONL)
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
### 保留天数
- `monitor_retention_days`:仅 **page_views**(默认 30)
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
## 地理数据文件
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
| 文件 | 用途 |
|------|------|
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
## 写入路径(性能)
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
**热路径零 Geo、零写库、零写文件。**
## 请求日志口径(排除规则)
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
- `/admin`、`/api/admin/`
- `/api/me`、`/api/site-branding`
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
## 实时与聚合
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
- 地理排行:仅 `page_views`。
## 诊断
```bash
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
```
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
## 明确不做
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB

View File

@@ -18,6 +18,24 @@
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。 把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 自定义单页(推荐)
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
| 文件 | 标题 | slug | 操作 |
| --- | --- | --- | --- |
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
访问路径:`/page/changelog`。
## 部署指南(置顶帖)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
## 可选短文 ## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md): 若站务区希望不止一篇,见 [optional.md](./optional.md):

View File

@@ -0,0 +1,193 @@
# 更新日志
> 建议标题:更新日志
> 建议 slug:`changelog`
> 建议路径:`/page/changelog`
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
---
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
**当前版本:1.1.8**(2026-09-02)
## 去哪看新版本
| 渠道 | 看什么 | 地址 |
| --- | --- | --- |
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
| Windows / Linux / macOS | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
**1.1.8 直接下载:**
- Windows x64:[jiang13_v1.1.8_windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_windows-amd64.exe)
- Linux x64:[jiang13_v1.1.8_linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64)
- macOS arm64:[jiang13_v1.1.8_darwin-arm64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_darwin-arm64)
拉取指定版本镜像:
```bash
docker pull hangzhang714128/jiang13-forum:1.1.8
# 或始终跟随最新
docker pull hangzhang714128/jiang13-forum:latest
```
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
## 升级注意
- 一般只需换镜像或换 exe,数据目录向后兼容。
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
---
## 📦 v1.1.8 · 2026-09-02
表情选择与评论富文本展示完善,私信表情不再发成图片附件。
**新增 / 调整**
- 表情选择器在评论富文本中可直接插入,与文章编辑器一致
- 私信页支持表情选择器,点击表情直接插入文本框
**修复**
- 私信中插入的表情改为文本内容发送,不再作为图片附件
- 消息未读数在多端间同步更及时
- 帖子详情快照逻辑修复,避免打开旧缓存页
---
## 📦 v1.1.7 · 2026-09-02
私信与开源展柜体验升级,待审通知的审核态更及时。
**新增 / 调整**
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
- 主页「发私信」直达消息页对应会话
- 开源展柜改为目录式 UI,空列表不再粘滞
- 消息页体验重构;待审通知打开后实时回填审核态
**修复**
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
---
## 📦 v1.1.6 · 2026-09-01
编辑器插入体验与首页默认 Feed 排序更合理。
**新增 / 调整**
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
- 链接是否新标签打开交由全站设置,对话框不再单独开关
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
**修复**
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
---
## 📦 v1.1.5 · 2026-09-01
首页首屏与交互更稳,Feed 排序语义更清楚。
本版起同时提供 **Docker 镜像** 与 **Windows / Linux 预编译单文件**(Gitea Release `v1.1.5`)。
**新增 / 调整**
- 首页改为 Go SSR 与 React hydrate(注水)同构,打开首页不再先闪一层空壳再跳内容
- Feed 排序改为:**新评论 / 新帖子 / 推荐帖**;推荐帖只出精华(featured)
- 点击排序会强制刷新列表;软刷新时同步站点限额与品牌文案
- 返回列表用缓存恢复滚动位置,不再重复请求
**修复**
- 发版后软刷新会检测入口壳;chunk(代码分片)404 时自动硬刷新,避免卡在旧页面
- 软刷新齐套前保留旧画面,避免一点击就把内容卸光
- 补齐 `/favicon.ico` 与页面 head 图标;拆除爬虫专用 HTML
- 桌面端隐藏多余的导航汉堡按钮
- 帖行评论数、列表「共 N 条」右对齐;排序栏数字紧跟标签
- 手机端 SSR 顶栏 / 页脚与 React 一致,减少闪动
---
## 📦 v1.1.4 · 2026-08-31
网站监控的地理信息与口径更准确。
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
- 对齐监控概览「双通道」口径文案与诊断抽样说明
---
## 📦 v1.1.3 · 2026-08-31
管理后台可看站点访问情况。
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
---
## 📦 v1.1.2 · 2026-08-31
可选接入官方社区展柜。
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
---
## 📦 v1.1.1 · 2026-08-31
- 评论管理操作收进「更多」菜单,管理员界面更干净
---
## 📦 v1.1.0 · 2026-08-30
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
**新增**
- 友链申请、独立友链页;页脚左右区域可分别开关
- 自定义单页(后台「单页管理」,如本页)
- 投票帖、悬赏帖、抽奖帖
- 侧栏签到;右侧栏「最新注册」
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
**优化**
- 首页帖子列表密度与标题样式
- 开源码桶展示加强,去掉 Feed 顶栏统计
- 单页编辑、列表留白与友链申请体验
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
**修复**
- 手机端搜索入口与底部 Sheet(抽屉面板)
- 占位头像对比度;无头像用户不再误用游客灰底
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
- 单页正文排版与帖子详情对齐
---
## 📦 v1.0.0 · 2026-08-23
首次发布 Docker 镜像 `hangzhang714128/jiang13-forum:1.0.0`。
当时已包含论坛核心能力:板块与发帖、楼层评论、点赞收藏、私信、TipTap 富文本、贴纸、修订历史、管理后台、OIDC(开放身份连接)/ SSO(单点登录)、邮件验证码、Gitea 仓库同步、SQLite 单二进制部署,以及 Docker 一键运行。
---
之后发版时:Docker 推 `x.y.z` + `latest`,Gitea 打 tag `vx.y.z` 并挂上对应 exe / linux 文件,再把新版本写到本页最上方。

View File

@@ -0,0 +1,244 @@
# 从零开始部署姜十三论坛
> 建议标题:从零开始部署姜十三论坛
> 建议 slug:`deploy`
> 建议操作:发布后设为「置顶」
> 复制正文时:从下方第一个 `---` **之后**开始粘贴
---
## 为什么选姜十三
单二进制 + 内置 SQLite,**不需要 MySQL、PHP、Redis**。面板(宝塔 / 1Panel)只是帮你更方便地管理,不是必须的。
## 服务器选型
| 部署方式 | CPU | 内存 | 磁盘 |
| --- | --- | --- | --- |
| 纯二进制(无面板) | 1 核 | 512MB | 10GB SSD |
| 二进制 + 宝塔 | 1 核 | 1GB | 20GB SSD |
| Docker + 1Panel | 2 核 | 2GB | 40GB SSD |
> 推荐 Debian 12 / Ubuntu 22.04。建议直接上 2 核 2G。
## 部署方式对比
| | 1Panel + Docker | 宝塔 + 二进制 | 纯命令行 Docker |
| --- | --- | --- | --- |
| 适合 | 新手 / 还要跑其他容器 | 习惯宝塔 / 只跑这一个 | 熟悉 Linux / 资源紧张 |
| 资源占用 | 较高(自带 Docker) | 低 | 最低 |
| HTTPS | 面板一键 | 面板一键 | Caddy / certbot |
| 备份 | 面板计划任务 | 面板计划任务 | `tar` 打包 |
| 升级 | 面板点一下 | 替换二进制 | `docker pull` + 重启 |
## 前置准备
```bash
apt update && apt upgrade -y
apt install -y curl wget ca-certificates
timedatectl set-timezone Asia/Shanghai
```
---
## 方式一:1Panel + Docker
### 1. 安装 1Panel
```bash
curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh \
-o quick_start.sh && bash quick_start.sh
```
- 安装目录:默认 `/opt`
- 是否装 Docker:**y**(必须)
- 面板端口 / 安全入口 / 账号密码自定义,记好
安全组放行设置的端口,然后浏览器打开面板。
### 2. 启动姜十三
1Panel 面板 → 容器 → 创建:
| 字段 | 值 |
| --- | --- |
| 镜像 | `hangzhang714128/jiang13-forum:latest` |
| 端口 | `3000:3000` |
| 数据卷 | 主机目录 → 容器内 **`/data`** |
| 重启策略 | `unless-stopped` |
浏览器访问 `http://IP:3000` → **首个注册用户自动成为管理员**。
> 若容器报 `permission denied`:`chown -R 1000:1000 /数据目录`
---
## 方式二:宝塔 + 二进制
### 1. 安装宝塔
```bash
# 正式版 13.0.0(含 AI,需绑定账号)
if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh KZOqff1mn
# 稳定版 12.0.0(纯净、免绑定)
url=https://download.bt.cn/install/installStable_12.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable_12.sh $url;fi;bash installStable_12.sh 5x59vxy56
```
安全组放行随机端口,浏览器打开(自签名证书提示 → 高级 → 继续前往)。
### 2. 下载并注册服务
```bash
mkdir -p /opt/jiang13 && cd /opt/jiang13
# 版本号以更新日志为准:https://bbs.iioio.com/page/update-log.html
wget -O jiang13 https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64
chmod +x jiang13
# 注册为 systemd 服务(install 创建 unit 文件,start 启动,enable 开机自启)
./jiang13 -service install
./jiang13 -service start
systemctl enable jiang13
# 日常管理
./jiang13 -service status # 查看状态
./jiang13 -service restart # 重启
# 验证
curl http://127.0.0.1:3000/health
```
> 只是临时跑一下?直接 `./jiang13` 前台启动即可,Ctrl+C 停止,不用 `install`。
### 2.1 升级二进制
```bash
cd /opt/jiang13
./jiang13 -service stop
wget -O jiang13.new https://git.iioio.com/freefire/jiang13-forum/releases/download/v新版本/jiang13_v新版本_linux-amd64
mv jiang13.new jiang13 && chmod +x jiang13
./jiang13 -service start
```
### 3. 宝塔反代 + SSL
网站 → 添加站点(域名,PHP 选**纯静态**)→ 反向代理(目标 `http://127.0.0.1:3000`)→ SSL → Let's Encrypt 一键签发 → 强制 HTTPS。
---
## 方式三:纯 Docker
```bash
# 安装 Docker
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
# 启动姜十三
docker run -d \
--name jiang13 \
-p 3000:3000 \
-v /opt/jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
或用 docker compose:
```yaml
services:
jiang13:
image: hangzhang714128/jiang13-forum:latest
ports: ["3000:3000"]
volumes: ["./data:/data"]
environment: {TZ: Asia/Shanghai}
restart: unless-stopped
```
```bash
docker compose up -d
```
HTTPS 用 Caddy 最省心:
```
bbs.yourdomain.com {
reverse_proxy 127.0.0.1:3000
}
```
Caddy 自动申请 Let's Encrypt。
---
## 域名 + HTTPS
域名服务商添加 **A 记录**:`bbs` → 服务器公网 IP。
| 方式 | HTTPS 操作 |
| --- | --- |
| 1Panel | 网站 → 创建 → 反向代理 → SSL → Let's Encrypt |
| 宝塔 | 同上 |
| 纯 Docker | Caddy 自动 / Nginx + certbot |
---
## 数据备份
所有数据(SQLite + 上传 + JWT 密钥)都在**一个目录**里,备份 = 复制目录。
| 方式 | 数据目录 |
| --- | --- |
| 1Panel Docker | 你映射的宿主机目录 |
| 宝塔二进制 | 二进制同目录下 `data/` |
| 纯 Docker | `-v` 映射的目录 |
```bash
tar -czf jiang13-backup-$(date +%Y%m%d).tar.gz /数据目录
```
> ⚠️ `data/.jwt_secret` 务必一起备份,丢了所有用户已登录的 cookie 会失效,需要重新输入密码登录(密码本身存 SQLite 里不受影响)。
---
## 版本升级
版本号以 [更新日志](https://bbs.iioio.com/page/update-log.html) 为准。
| 方式 | 升级 |
| --- | --- |
| 1Panel | 容器列表 → 选中 → 镜像更新 |
| docker compose | `docker compose pull && docker compose up -d` |
| 二进制 | 见上方「2.1 升级二进制」 |
升级前先备份:`tar -czf backup.tar.gz /数据目录`
---
## 面板常用命令
| 操作 | 宝塔 | 1Panel |
| --- | --- | --- |
| 菜单 | `bt` | `1pctl` |
| 查入口 / 密码 | `bt default` | `1pctl user-info` |
| 改密码 | `bt 14` | `1pctl update password` |
| 重启面板 | `bt restart` | — |
## 常见问题
**Q:面板 / 网站打不开?** 安全组有没有放行对应端口?`netstat -tlnp | grep 端口` 看服务在不在监听。
**Q:宝塔 vs 1Panel 怎么选?** 只跑姜十三 → 宝塔 + 二进制(省资源);还要跑别的 → 1Panel。
**Q:支持邮件 / SSO 吗?** 邮件 SMTP 和 OIDC SSO 都在**管理后台 → 系统设置**里配置。
**Q:源码编译?** 见项目 README。推荐用预编译包。
## 链接
| 项目 | 地址 |
| --- | --- |
| 在线演示 | https://bbs.iioio.com/ |
| 更新日志 | https://bbs.iioio.com/page/update-log.html |
| Docker Hub | hangzhang714128/jiang13-forum |
| 预编译下载 | https://git.iioio.com/freefire/jiang13-forum/releases |
有问题欢迎本帖留言。

View File

@@ -1,87 +0,0 @@
package embed_static
import (
"embed"
"io/fs"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
var (
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
spaBrandTitleFn func() string
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
)
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
func SetSPADocumentTitle(fn func() string) {
spaBrandTitleFn = fn
}
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
func SetSPABrandingJSON(fn func() []byte) {
spaBrandJSONFn = fn
}
// SetupEmbed 配置内嵌资源:React SPA 静态资源
func SetupEmbed(r *gin.Engine) error {
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
r.GET("/assets/*filepath", func(c *gin.Context) {
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
c.Header("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
r.GET("/stickers/*filepath", func(c *gin.Context) {
// stickers 不含哈希指纹,设置适中的缓存
c.Header("Cache-Control", "public, max-age=86400")
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
return nil
}
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
func ServeSPA(c *gin.Context) {
ServeSPAWithMeta(c, nil)
}
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
func ServeSPANoIndex(c *gin.Context) {
title := ""
if spaBrandTitleFn != nil {
title = strings.TrimSpace(spaBrandTitleFn())
}
ServeSPAWithMeta(c, &SPAPageMeta{
Title: title,
Robots: "noindex,nofollow",
})
}
// IsSPARoute 判断是否应由 SPA 处理
func IsSPARoute(path string) bool {
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
return false
}
if strings.HasPrefix(path, "/api") ||
strings.HasPrefix(path, "/admin") ||
strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/media") ||
strings.HasPrefix(path, "/assets") ||
strings.HasPrefix(path, "/stickers") ||
strings.HasPrefix(path, "/oauth") ||
strings.HasPrefix(path, "/.well-known") {
return false
}
return true
}

View File

@@ -1,156 +0,0 @@
package embed_static
import (
"bytes"
"encoding/json"
"html"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
type SPAPageMeta struct {
Title string // 完整 <title>
Description string
Keywords string // meta keywords
Canonical string
OGType string // 默认 website
OGImage string
SiteName string // og:site_name
Locale string // og:locale,默认 zh_CN
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码,0 视为 200
}
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
status := http.StatusOK
if meta != nil && meta.Status != 0 {
status = meta.Status
}
data, err := staticFS.ReadFile("static/spa/index.html")
if err != nil {
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
return
}
data = applySPAPageMeta(data, meta)
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
c.Header("Cache-Control", "no-cache")
c.Data(status, "text/html; charset=utf-8", data)
}
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
if meta == nil {
meta = &SPAPageMeta{}
}
title := strings.TrimSpace(meta.Title)
if title == "" && spaBrandTitleFn != nil {
title = strings.TrimSpace(spaBrandTitleFn())
}
if title != "" {
escaped := html.EscapeString(title)
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
}
var head strings.Builder
writeMeta(&head, "description", meta.Description)
writeMeta(&head, "keywords", meta.Keywords)
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
}
robots := strings.TrimSpace(meta.Robots)
if robots != "" {
writeMeta(&head, "robots", robots)
}
ogType := strings.TrimSpace(meta.OGType)
if ogType == "" {
ogType = "website"
}
locale := strings.TrimSpace(meta.Locale)
if locale == "" {
locale = "zh_CN"
}
writeProp(&head, "og:type", ogType)
writeProp(&head, "og:site_name", meta.SiteName)
writeProp(&head, "og:locale", locale)
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&head, "og:description", meta.Description)
writeProp(&head, "og:url", meta.Canonical)
writeProp(&head, "og:image", meta.OGImage)
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&head, "twitter:description", meta.Description)
writeMetaName(&head, "twitter:image", meta.OGImage)
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
head.WriteString(`<script type="application/ld+json">`)
head.WriteString(jsonld)
head.WriteString(`</script>`)
}
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
if boot := spaBrandingBootScript(); boot != "" {
head.WriteString(boot)
}
if head.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
}
return data
}
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
func spaBrandingBootScript() string {
if spaBrandJSONFn == nil {
return ""
}
raw := bytes.TrimSpace(spaBrandJSONFn())
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
// 防止 JSON 字符串中的 </script> 提前闭合标签
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
}
func writeMeta(b *strings.Builder, name, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
}
func writeMetaName(b *strings.Builder, name, content string) {
writeMeta(b, name, content)
}
func writeProp(b *strings.Builder, prop, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
}
func twitterCard(ogImage string) string {
if strings.TrimSpace(ogImage) != "" {
return "summary_large_image"
}
return "summary"
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}

View File

@@ -4,7 +4,7 @@
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.ts", "config": "",
"css": "src/styles/global.css", "css": "src/styles/global.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,

View File

@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="拾三一隅,自在交流" />
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; touch-action: pan-x pan-y; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-header { height: 56px; flex-shrink: 0; }
.site-footer { flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; touch-action: pan-x pan-y; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; touch-action: pan-x pan-y; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
})();
</script>
<script>
/* 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
(function () {
function inZoomAllowArea(target) {
return !!(
target &&
target.closest &&
target.closest('.avatar-crop-stage, .image-lightbox')
);
}
function blockGesture(e) {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
}
function blockMultiTouch(e) {
if (e.touches && e.touches.length > 1) {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
}
}
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,10 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vike dev",
"build": "vite build", "build": "vike build",
"preview": "vite preview" "preview": "vike preview",
"start": "node server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -19,7 +20,9 @@
"@radix-ui/react-label": "^2.1.9", "@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2", "@svg-maps/china": "^2.0.0",
"@svg-maps/world": "^2.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tiptap/core": "^3.26.1", "@tiptap/core": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1",
"@tiptap/extension-image": "^3.26.1", "@tiptap/extension-image": "^3.26.1",
@@ -30,35 +33,44 @@
"@tiptap/pm": "^3.26.1", "@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1", "@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1", "@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compression": "^1.8.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0", "diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"express": "^5.2.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"http-proxy-middleware": "^4.2.0",
"linkedom": "^0.18.13",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"postcss": "^8.5.15", "react": "^19.2.8",
"react": "^18.3.1", "react-dom": "^19.2.8",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2", "react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0", "react-router": "^8.3.1",
"react-streaming": "^0.4.20",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19", "tailwindcss": "^4.3.3",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4", "turndown": "^7.2.4",
"zod": "^4.4.3" "tw-animate-css": "^1.4.0",
"vike": "^0.4.266",
"zod": "^4.4.3",
"zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^18.3.12", "@types/react": "^19.2.18",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^6.1.1",
"typescript": "^5.6.3", "typescript": "^7.0.2",
"vite": "^5.4.11" "vite": "^8.2.2"
},
"allowScripts": {
"esbuild@0.28.2": true
} }
} }

12
frontend/pages/+config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Config } from 'vike/types';
export default {
// pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
passToClient: ['pageProps', 'data'],
// 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
meta: {
ssr: {
env: { server: true },
},
},
} satisfies Config;

View File

@@ -0,0 +1,10 @@
import App from '../../src/App';
import type { PageContext } from 'vike/types';
export { Page };
/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */
function Page(pageContext: PageContext & { url?: string }) {
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
return <App url={url} />;
}

View File

@@ -0,0 +1,24 @@
import type { PageContextServer } from 'vike/types';
import type { FeedData } from '../../src/ssr/feedSeed';
export { data };
type NotFoundAbortReason = {
head?: FeedData['head'];
boot?: FeedData['boot'];
};
/**
* 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }),
* 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。
*/
async function data(pageContext: PageContextServer): Promise<FeedData> {
if (!import.meta.env.SSR) return { kind: 'notfound' };
const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
return {
kind: 'notfound',
head: reason?.head,
boot: reason?.boot,
};
}

View File

@@ -0,0 +1,11 @@
import App from '../../src/App';
import type { PageContext } from 'vike/types';
export { Page };
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
function Page(pageContext: PageContext & { url?: string }) {
// onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
return <App url={url} />;
}

View File

@@ -0,0 +1,9 @@
import type { PageContextServer } from 'vike/types';
import { loadFeedData } from '../../src/ssr/feedData';
export { data };
// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建
async function data(pageContext: PageContextServer) {
return await loadFeedData(pageContext);
}

View File

@@ -0,0 +1,11 @@
import type { PageContext } from 'vike/types';
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
export { route };
/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */
function route(pageContext: PageContext) {
const match = matchFeedSsrPath(pageContext.urlPathname);
if (!match) return false;
return { routeParams: { kind: match.kind, id: match.id } };
}

View File

@@ -0,0 +1,8 @@
import App from '../../src/App';
export { Page };
/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
function Page() {
return <App />;
}

View File

@@ -0,0 +1,10 @@
import type { PageContext } from 'vike/types';
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
export { route };
/** catch-all:SSR feed 白名单之外的一切 URL */
function route(pageContext: PageContext) {
if (matchFeedSsrPath(pageContext.urlPathname)) return false;
return { routeParams: {} };
}

View File

@@ -0,0 +1,4 @@
export { ssr };
/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
const ssr = false;

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,98 @@
import { StrictMode } from 'react';
import type { ComponentType } from 'react';
import { hydrateRoot, createRoot } from 'react-dom/client';
import type { PageContextClient } from 'vike/types';
import { applyTheme, getStoredTheme } from '../src/utils/theme';
import { installZoomGuard } from '../src/utils/zoomGuard';
import { beginHomeHydrate } from '../src/utils/homeHydrate';
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
export { onRenderClient };
type PageComponent = ComponentType<{ url?: string }>;
let root: ReturnType<typeof createRoot> | null = null;
function waitForStylesheets(): Promise<void> {
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
if (links.length === 0) return Promise.resolve();
return Promise.all(
links.map(
(node) =>
new Promise<void>((resolve) => {
const link = node as HTMLLinkElement;
try {
if (link.sheet) {
resolve();
return;
}
} catch {
/* cross-origin */
}
const finish = () => resolve();
link.addEventListener('load', finish, { once: true });
link.addEventListener('error', finish, { once: true });
}),
),
).then(() => undefined);
}
async function onRenderClient(pageContext: PageContextClient) {
applyTheme(getStoredTheme());
installZoomGuard();
// hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
if (pageContext.isHydration) {
seedFeedData((pageContext as { data?: FeedData }).data);
}
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
reloadForStaleChunk();
});
const el = document.getElementById('root');
if (!el) return;
// 有 SSR 正文 → hydrate 接管同一 DOM(首帧数据已由 +data seed,无需冷启动预热);
// SPA 壳 / SSR 降级空壳 → createRoot 挂载,挂载前先冷启动预热,避免空白首帧
const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
// 仅非 hydrate 路径需要冷启动:SSR 页 seed 已灌入缓存,冷启动只会重复请求并阻塞 hydrate
if (!hydrating && isMainLayoutPath(window.location.pathname)) {
const path = `${window.location.pathname}${window.location.search}`;
await ensureColdBootReady(path);
}
await waitForStylesheets();
try {
if (document.fonts?.ready) await document.fonts.ready;
} catch {
/* ignore */
}
if (hydrating) beginHomeHydrate();
const Page = pageContext.Page as PageComponent;
const app = (
<StrictMode>
<Page />
</StrictMode>
);
if (hydrating) {
try {
hydrateRoot(el, app);
} catch (err) {
console.warn('[j13] hydrateRoot 失败,回退 createRoot', err);
el.innerHTML = '';
if (!root) root = createRoot(el);
root.render(app);
}
} else {
if (!root) root = createRoot(el);
root.render(app);
}
}

View File

@@ -0,0 +1,78 @@
import type { ComponentType, ReactElement } from 'react';
import { renderToReadableStream } from 'react-dom/server';
import { escapeInject, dangerouslySkipEscape } from 'vike/server';
import type { PageContextServer } from 'vike/types';
import { createHomeStore } from '../src/store/homeStore';
import { createRequestScope } from '../src/ssr/scopeTypes';
import { runWithScope } from '../src/ssr/scope';
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
import { renderHeadTags } from '../src/ssr/head';
import { installServerDom } from '../src/ssr/serverDom';
import { readThemeFromCookie } from '../src/utils/theme';
import { createSsrApp } from '../src/App';
// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装
installServerDom();
export { onRenderHtml };
type PageComponent = ComponentType<{ url?: string }>;
/** 等 lazy 页面全部渲染完成(allReady)后缓冲为完整 HTML,首字节语义与旧 Go 文档级 SSR 一致 */
async function renderPageToString(element: ReactElement): Promise<string> {
const stream = await renderToReadableStream(element, {
onError(err) {
console.error('[j13] SSR 渲染错误:', err);
},
});
await stream.allReady;
return new Response(stream).text();
}
async function onRenderHtml(pageContext: PageContextServer) {
const Page = pageContext.Page as PageComponent | undefined;
const data = (pageContext as { data?: FeedData }).data;
let pageHtml = '';
if (Page) {
// SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500
const { pathname, searchOriginal } = pageContext.urlParsed;
const url = pathname + (searchOriginal ?? '');
const scope = createRequestScope(createHomeStore(), url);
scope.hydrating = true;
try {
pageHtml = await runWithScope(scope, async () => {
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
seedFeedData(data);
// 官方 SSR 路径:await 首导航后 StaticRouterProvider 同步渲染(避免 RouterProvider suspend
// 导致内容落入 <div hidden>,禁 JS 爬虫不可见)
const app = await createSsrApp(url);
return renderPageToString(app);
});
} catch (err) {
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
pageHtml = '';
}
}
const headTags = renderHeadTags(data?.head);
// 主题从请求 Cookie 读取,首帧直接渲染到 <html> 上(深色用户无白屏闪烁,无需内联脚本)
// headersOriginal 经 Vike 归一化为 pageContext.headers(键全小写)
const cookieHeader = (pageContext as { headers?: Record<string, string> | null }).headers?.cookie;
const dark = readThemeFromCookie(cookieHeader) === 'dark';
const htmlAttrs = dark ? ' class="dark" style="color-scheme:dark"' : '';
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
return escapeInject`<!DOCTYPE html>
<html lang="zh-CN"${dangerouslySkipEscape(htmlAttrs)}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${dangerouslySkipEscape(headTags)}
</head>
<body>
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`;
}

121
frontend/server/index.mjs Normal file
View File

@@ -0,0 +1,121 @@
/**
* Node 渲染服务器(生产模式)。
*
* 双进程架构对外单点:
* - Node :3000 —— 前端静态资源 + Vike SSR(/、/board/:id、/post/:id 真渲染,其余 SPA 壳)
* - Go :3001 —— 纯 API(/api、/uploads、/media、/oauth、robots/sitemap/favicon 反代至此)
*
* dev 模式不经过本文件:`npm run dev`(vike dev :5173)+ Go dev(API :3000)。
*/
import { Readable } from 'node:stream';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import express from 'express';
import compression from 'compression';
import { createProxyMiddleware } from 'http-proxy-middleware';
// 本文件仅用于生产(dev 走 `vike dev`);须在加载 vike/server 前定型环境
process.env.NODE_ENV ??= 'production';
const { renderPage } = await import('vike/server');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const distClient = path.join(root, 'dist', 'client');
const port = Number(process.env.PORT || 3000);
const goApiUrl = process.env.GO_API_URL || 'http://127.0.0.1:3001';
/** 反代到 Go 的前缀(对齐旧 Go ServePublicSPA 的资源分发面) */
const GO_PROXY_PREFIXES = [
'/api',
'/uploads',
'/media',
'/oauth',
'/.well-known',
'/robots.txt',
'/sitemap.xml',
'/favicon.ico',
];
if (!fs.existsSync(distClient)) {
console.error(`[j13-web] 未找到构建产物 ${distClient},请先执行 npm run build`);
process.exit(1);
}
const app = express();
app.disable('x-powered-by');
app.use(compression());
// Node 自身探活(不依赖 Go,供容器健康检查)
app.get('/healthz', (_req, res) => {
res.status(200).send('ok');
});
// Go 反代:透传 method/headers/body;xfwd 附加 X-Forwarded-*,供 Go 推导站点 base URL。
// express app.use(prefix) 会剥掉挂载前缀(req.url 不含 prefix),故 target 须补回对应前缀
for (const prefix of GO_PROXY_PREFIXES) {
app.use(
prefix,
createProxyMiddleware({
target: `${goApiUrl}${prefix}`,
xfwd: true,
}),
);
}
// 前端静态资源:dist/client(含 /assets 与 /stickers),assets 带 contenthash → immutable;
// HTML 用 no-cache(每次协商),避免 no-store/max-age=0 导致浏览器丢弃历史滚动状态(F5 不恢复位置)
app.use(
express.static(distClient, {
cacheControl: false, // 关闭 send 自动写入的 Cache-Control,统一由 setHeaders 决定
setHeaders(res, filePath) {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
res.setHeader('Cache-Control', 'no-cache');
}
},
}),
);
// 其余路径交给 Vike:feed 页 SSR 文档,spa 页输出壳(客户端接管)
app.use(async (req, res, next) => {
try {
const pageContext = await renderPage({
urlOriginal: req.originalUrl,
headersOriginal: req.headers,
});
const { httpResponse } = pageContext;
if (!httpResponse) return next();
const { statusCode, headers, body } = httpResponse;
res.statusCode = statusCode;
for (const [name, value] of headers) res.setHeader(name, value);
// Vike 默认对 HTML 发 no-store:会使浏览器丢弃该历史条目的滚动恢复状态(F5 回到顶部)。
// 改为 no-cache:文档仍每次重新验证/渲染,但保留浏览器原生 scroll restoration。
res.setHeader('Cache-Control', 'no-cache');
if (typeof body === 'string') {
res.send(body);
} else {
Readable.fromWeb(body).pipe(res);
}
} catch (err) {
// feedData/onRenderHtml 已内置降级(Go 不可达 → SPA 壳),此处仅兜 renderPage 本身的异常
console.error('[j13-web] renderPage 失败:', err);
if (!res.headersSent) {
res
.status(500)
.type('html')
.send(
'<!DOCTYPE html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>服务器错误</title><body><p>页面渲染失败,请刷新重试。</p></body></html>',
);
} else {
res.end();
}
}
});
app.listen(port, () => {
console.log(`[j13-web] Node 渲染服务器已启动: http://localhost:${port} (Go API: ${goApiUrl})`);
});

View File

@@ -1,106 +1,43 @@
import { Suspense } from 'react'; import { BrowserRouter, StaticRouter, useRoutes } from 'react-router';
import { import AppShell from './AppShell';
createBrowserRouter, import { appRoutes } from './AppRoutes';
createRoutesFromElements,
RouterProvider,
Route,
Navigate,
} from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import ErrorBoundary from './components/ErrorBoundary';
import AppRouteError from './components/AppRouteError';
import PageLoader from './components/PageLoader';
import AuthPageFallback from './components/AuthPageFallback';
import { Toaster } from './components/ui/sonner';
import PullToRefresh from './components/PullToRefresh';
import { lazyWithRetry } from './utils/lazyWithRetry';
const HomePage = lazyWithRetry(() => import('./pages/HomePage')); /**
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage')); * 路由树:使用低层 router(useRoutes)而非 data router(RouterProvider)。
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage')); *
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage')); * 原因:data router 的 RouterProvider/StaticRouterProvider 内部订阅路由状态,
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')); * 服务端 Fizz 渲染会产生 Suspense pending boundary(内容落入 <div hidden> +
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage')); * 脚本替换的流式格式,禁 JS 爬虫/SEO 工具看不到帖子)。本项目数据全部由
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage')); * Vike +data 预取并 seed 进请求作用域,不使用 loader/action,低层路由首帧
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage')); * 即可同步渲染完整 HTML,SSR/CSR 行为一致。
const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage')); */
const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage')); function AppRoutes() {
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); return useRoutes(appRoutes);
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); }
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
const router = createBrowserRouter( /**
createRoutesFromElements( * 应用根组件:Vike +Page 与客户端入口共用(浏览器侧)。
<Route errorElement={<AppRouteError />}> */
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} /> export default function App(_props: { url?: string }) {
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
<Route path="/forgot-password" element={<Suspense fallback={<AuthPageFallback />}><ForgotPasswordPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="pages/new" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/board/:id" element={<HomePage />} />
{/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/user/:id" element={<UserProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/links" element={<LinksPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
</Route>
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
</Route>,
),
);
export default function App() {
return ( return (
<ThemeProvider> <BrowserRouter>
<AuthProvider> <AppShell>
<ErrorBoundary> <AppRoutes />
<RouterProvider router={router} /> </AppShell>
<PullToRefresh /> </BrowserRouter>
<Toaster /> );
</ErrorBoundary> }
</AuthProvider>
</ThemeProvider> /**
* SSR 应用工厂:低层 StaticRouter + useRoutes,location 一次性给定,
* 整棵树同步渲染(无首导航异步、无状态订阅 suspend)。
*/
export async function createSsrApp(url: string) {
return (
<StaticRouter location={url}>
<AppShell>
<AppRoutes />
</AppShell>
</StaticRouter>
); );
} }

View File

@@ -0,0 +1,89 @@
import { Suspense } from 'react';
import { createRoutesFromElements, Route, Navigate } from 'react-router';
import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import AppRouteError from './components/AppRouteError';
import PageLoader from './components/PageLoader';
import AuthPageFallback from './components/AuthPageFallback';
import { lazyWithRetry } from './utils/lazyWithRetry';
// SSR 页面(/、/board/:id、/post/:id)必须静态导入:React.lazy 在服务端首帧必然
// suspend,会使内容落入 <div hidden> + 脚本替换的流式格式,禁 JS 爬虫/SEO 工具看不到内容。
import HomePage from './pages/HomePage';
import PostDetailPage from './pages/PostDetailPage';
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
/** 全量 react-router 路由树(Vike 页面壳与浏览器共用同一棵树) */
export const appRoutes = createRoutesFromElements(
<Route errorElement={<AppRouteError />}>
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
<Route path="/forgot-password" element={<Suspense fallback={<AuthPageFallback />}><ForgotPasswordPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="pages/new" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
<Route path="community" element={<Suspense fallback={<PageLoader />}><AdminCommunityPage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
<Route path="monitor" element={<Suspense fallback={<PageLoader />}><AdminMonitorPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/board/:id" element={<HomePage />} />
{/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
<Route path="/post/:id" element={<PostDetailPage />} />
{/* 懒加载页面各自包裹 Suspense:Suspense 边界不得包裹 SSR 静态内容
(React 19 会把边界内大体积/含 <img> 的内容 outlining 为 hidden chunk) */}
<Route path="/compose" element={<Suspense fallback={<PageLoader />}><ComposePage /></Suspense>} />
<Route path="/post/:id/edit" element={<Suspense fallback={<PageLoader />}><ComposePage /></Suspense>} />
<Route path="/profile" element={<Suspense fallback={<PageLoader />}><ProfilePage /></Suspense>} />
<Route path="/user/:id" element={<Suspense fallback={<PageLoader />}><UserProfilePage /></Suspense>} />
<Route path="/favorites" element={<Suspense fallback={<PageLoader />}><FavoritesPage /></Suspense>} />
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
<Route path="/links" element={<Suspense fallback={<PageLoader />}><LinksPage /></Suspense>} />
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
<Route path="/messages" element={<Suspense fallback={<PageLoader />}><MessagesPage /></Suspense>} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
</Route>
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
</Route>,
);

24
frontend/src/AppShell.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
import ErrorBoundary from './components/ErrorBoundary';
import { Toaster } from './components/ui/sonner';
import PullToRefresh from './components/PullToRefresh';
import TopProgressBar from './components/TopProgressBar';
/** 与路由无关的应用壳:Provider + 全局挂件(SSR 服务端 / 客户端共用) */
export default function AppShell({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
{children}
<TopProgressBar />
<PullToRefresh />
<Toaster />
</ErrorBoundary>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -1,4 +1,4 @@
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types'; import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem, MonitorConfig, MonitorOverview, MonitorGeoResult, MonitorStatItem, MonitorRealtime, MonitorLogItem } from './types';
const BASE = ''; const BASE = '';
@@ -25,6 +25,27 @@ export const api = {
me: () => request<{ user: User | null }>('/api/me'), me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'), stats: () => request<ForumStats>('/api/stats'),
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'), forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
/** 前台路由 pageview;失败静默(204 无 body) */
monitorPageview: (body: { path: string; referrer?: string }) => {
const payload = JSON.stringify(body);
try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([payload], { type: 'application/json' });
if (navigator.sendBeacon('/api/monitor/pageview', blob)) {
return Promise.resolve();
}
}
} catch {
/* 回退 fetch */
}
return fetch('/api/monitor/pageview', {
method: 'POST',
credentials: 'omit',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload,
}).then(() => undefined).catch(() => undefined);
},
siteBranding: () => request<SiteBranding>('/api/site-branding'), siteBranding: () => request<SiteBranding>('/api/site-branding'),
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'), pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`), page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
@@ -63,6 +84,59 @@ export const api = {
// 管理后台 API // 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'), adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'), adminSettings: () => request<AdminSettings>('/api/admin/settings'),
adminUpdateCommunitySettings: (body: CommunityConfig) =>
request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', {
method: 'PUT', body: JSON.stringify(body),
}),
adminMonitorSettings: () => request<MonitorConfig>('/api/admin/settings/monitor'),
adminUpdateMonitorSettings: (body: MonitorConfig) =>
request<{ message: string; monitor: MonitorConfig }>('/api/admin/settings/monitor', {
method: 'PUT', body: JSON.stringify(body),
}),
adminMonitorOverview: () => request<MonitorOverview>('/api/admin/monitor/overview'),
adminMonitorGeo: (range = '30d') =>
request<MonitorGeoResult>(`/api/admin/monitor/geo?range=${encodeURIComponent(range)}`),
adminMonitorStats: (dim: string, range = '30d') =>
request<{ dim: string; range: string; items: MonitorStatItem[] }>(
`/api/admin/monitor/stats?dim=${encodeURIComponent(dim)}&range=${encodeURIComponent(range)}`,
),
adminMonitorLogs: (params: { page?: number; size?: number; method?: string; path?: string; status?: string; ip?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.size) q.set('size', String(params.size));
if (params.method) q.set('method', params.method);
if (params.path) q.set('path', params.path);
if (params.status) q.set('status', params.status);
if (params.ip) q.set('ip', params.ip);
const qs = q.toString();
return request<{ items: MonitorLogItem[]; total: number; page: number; size: number }>(
`/api/admin/monitor/logs${qs ? `?${qs}` : ''}`,
);
},
adminMonitorRealtime: () => request<MonitorRealtime>('/api/admin/monitor/realtime'),
adminCommunityInstances: () =>
request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'),
adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) =>
request<{ message: string; instance: CommunityInstance }>(
`/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`,
{ method: 'PUT', body: JSON.stringify(body) },
),
adminUpdateShowcaseEntry: (body: {
nav_show_showcase?: boolean;
footer_show_showcase?: boolean;
aside_show_showcase?: boolean;
}) =>
request<{
message: string;
nav_show_showcase: boolean;
footer_show_showcase: boolean;
aside_show_showcase: boolean;
}>('/api/admin/community/showcase-entry', {
method: 'PUT',
body: JSON.stringify(body),
}),
communityShowcase: () =>
request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'),
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => { adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page)); if (params.page) q.set('page', String(params.page));
@@ -80,12 +154,12 @@ export const api = {
}, },
adminApprovePost: (id: number) => adminApprovePost: (id: number) =>
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }), request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
adminPinPost: (id: number, pinned: boolean) => adminPinPost: (id: number, pinned: number) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, { request<{ message: string; pinned: number }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }), method: 'POST', body: JSON.stringify({ pinned }),
}), }),
adminBoardPinPost: (id: number, boardPinned: boolean) => adminBoardPinPost: (id: number, boardPinned: number) =>
request<{ message: string; board_pinned: boolean }>(`/api/admin/posts/${id}/board-pin`, { request<{ message: string; board_pinned: number }>(`/api/admin/posts/${id}/board-pin`, {
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }), method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
}), }),
adminFeaturePost: (id: number, featured: boolean) => adminFeaturePost: (id: number, featured: boolean) =>
@@ -190,8 +264,6 @@ export const api = {
}), }),
postRevisions: (id: number) => postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`), request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }), adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminTrashPosts: (params: { page?: number; keyword?: string }) => { adminTrashPosts: (params: { page?: number; keyword?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
@@ -345,6 +417,14 @@ export const api = {
fd.append('image', file); fd.append('image', file);
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} }); return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
}, },
/** 当前用户历史上传的帖子图片 */
myPostImages: (params?: { page?: number; size?: number }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.size) q.set('size', String(params.size));
const qs = q.toString();
return request<MediaListResult>(`/api/uploads/images${qs ? `?${qs}` : ''}`);
},
createPost: (data: { createPost: (data: {
board_id: string; title: string; content: string; tags?: string; post_type?: string; board_id: string; title: string; content: string; tags?: string; post_type?: string;
poll_options?: string; bounty_points?: number; lottery_winner_count?: number; poll_options?: string; bounty_points?: number; lottery_winner_count?: number;
@@ -500,7 +580,6 @@ export const api = {
`/api/users/search?${sp}`, `/api/users/search?${sp}`,
); );
}, },
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
logout: () => request('/api/logout', { method: 'POST' }), logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }), like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }), likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
@@ -582,6 +661,8 @@ export const api = {
}, },
markNotificationsRead: () => markNotificationsRead: () =>
request<{ message: string }>('/api/messages/notifications/read', { method: 'POST' }), request<{ message: string }>('/api/messages/notifications/read', { method: 'POST' }),
markMessageRead: (id: number) =>
request<{ message: string }>(`/api/messages/${id}/read`, { method: 'POST' }),
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) => sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
request<{ message: PrivateMessage }>('/api/messages', { request<{ message: PrivateMessage }>('/api/messages', {
method: 'POST', body: JSON.stringify(body), method: 'POST', body: JSON.stringify(body),

View File

@@ -0,0 +1,40 @@
/**
* 服务端 API 客户端:Vike +data 在 Node 侧调用 Go API 用(仅 SSR 打包引用)。
*
* - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001)
* - 转发浏览器 cookie 以透传登录态
* - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等)
* - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染
*
* 本模块仅允许被 SSR 侧代码(pages 下的 +data、src/ssr/feedData)引用;
* 顶层 process.env 读取以 import.meta.env.SSR 守卫,防止误入客户端包时求值崩溃。
*/
const GO_API_URL = import.meta.env.SSR
? process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`
: '';
export type ServerApiOptions = {
/** 浏览器请求的 cookie 原样转发 */
cookie?: string;
/** 额外转发给 Go 的请求头(host / x-forwarded-* 等) */
headers?: Record<string, string>;
timeoutMs?: number;
};
export async function serverApi<T>(path: string, opts?: ServerApiOptions): Promise<T | null> {
try {
const res = await fetch(`${GO_API_URL}${path}`, {
headers: {
...(opts?.cookie ? { cookie: opts.cookie } : {}),
...(opts?.headers ?? {}),
},
signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000),
cache: 'no-store',
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}

View File

@@ -83,7 +83,12 @@ export interface PostItem {
board_id: number; board_id: number;
user_id: number; user_id: number;
title: string; title: string;
/** 完整正文;列表接口不下发(为空),仅帖子详情等场景有值 */
content?: string; content?: string;
/** 列表接口预算的纯文本摘要(60 字),列表页直接使用无需解析 content */
excerpt?: string;
/** 列表接口预算的正文首图 URL(缩略图模式用) */
first_image?: string;
tags: string; tags: string;
/** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */ /** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */
post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string; post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string;
@@ -94,9 +99,9 @@ export interface PostItem {
bounty_comment_id?: number; bounty_comment_id?: number;
lottery_winner_count?: number; lottery_winner_count?: number;
lottery_status?: 'open' | 'drawn' | string; lottery_status?: 'open' | 'drawn' | string;
pinned: boolean; pinned: number;
/** 板块内置顶(仅板块列表抬升,首页不抬升) */ /** 板块内置顶(仅板块列表抬升,首页不抬升) */
board_pinned?: boolean; board_pinned?: number;
featured?: boolean; featured?: boolean;
edit_locked?: boolean; edit_locked?: boolean;
/** 禁止新评论(结贴) */ /** 禁止新评论(结贴) */
@@ -179,6 +184,14 @@ export interface Comment {
reply_target?: Comment; reply_target?: Comment;
} }
export interface AdminDashboardTraffic {
enabled: boolean;
today_pv: number;
today_uv: number;
yesterday_pv: number;
total_pv: number;
}
export interface AdminDashboard { export interface AdminDashboard {
users: number; users: number;
posts: number; posts: number;
@@ -189,9 +202,10 @@ export interface AdminDashboard {
pending_reports?: number; pending_reports?: number;
pending_friend_links?: number; pending_friend_links?: number;
recent_posts: PostItem[]; recent_posts: PostItem[];
traffic?: AdminDashboardTraffic;
} }
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links'; export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links' | 'showcase';
export interface AsideWidget { export interface AsideWidget {
id: AsideWidgetId; id: AsideWidgetId;
@@ -203,6 +217,21 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
{ id: 'recent_comments', enabled: false }, { id: 'recent_comments', enabled: false },
{ id: 'recent_users', enabled: false }, { id: 'recent_users', enabled: false },
{ id: 'friend_links', enabled: true }, { id: 'friend_links', enabled: true },
{ id: 'showcase', enabled: false },
];
export type FeedSortId = 'reply' | 'latest' | 'hot';
export interface FeedSortTab {
id: FeedSortId;
label: string;
enabled: boolean;
}
export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [
{ id: 'reply', label: '最新', enabled: true },
{ id: 'latest', label: '新帖子', enabled: true },
{ id: 'hot', label: '推荐帖', enabled: true },
]; ];
export interface ForumLimits { export interface ForumLimits {
@@ -231,14 +260,22 @@ export interface ForumLimits {
aside_show_recent_comments: boolean; aside_show_recent_comments: boolean;
/** 右侧栏友情链接 */ /** 右侧栏友情链接 */
aside_show_friend_links: boolean; aside_show_friend_links: boolean;
/** 右侧栏开源展柜 */
aside_show_showcase: boolean;
/** 右侧栏可选组件顺序与开关 */ /** 右侧栏可选组件顺序与开关 */
aside_widgets: AsideWidget[]; aside_widgets: AsideWidget[];
/** 左侧栏「站点」展示友情链接入口 */ /** 左侧栏「站点」展示友情链接入口 */
nav_show_friend_links: boolean; nav_show_friend_links: boolean;
/** 页脚展示友情链接入口 */ /** 页脚展示友情链接入口 */
footer_show_friend_links: boolean; footer_show_friend_links: boolean;
/** 左侧栏「站点」展示开源展柜入口 */
nav_show_showcase: boolean;
/** 页脚展示开源展柜入口 */
footer_show_showcase: boolean;
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */ /** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
feed_list_style: 'title' | 'excerpt' | 'thumbnail'; feed_list_style: 'title' | 'excerpt' | 'thumbnail';
/** 首页排序标签:名称、顺序、启停 */
feed_sort_tabs: FeedSortTab[];
/** 伪静态(固定链接)开关 */ /** 伪静态(固定链接)开关 */
permalink_enabled: boolean; permalink_enabled: boolean;
/** 伪静态后缀,不含点,如 html / htm */ /** 伪静态后缀,不含点,如 html / htm */
@@ -262,12 +299,18 @@ export interface ForumLimitsPublic {
aside_show_tag_cloud: boolean; aside_show_tag_cloud: boolean;
aside_show_recent_comments: boolean; aside_show_recent_comments: boolean;
aside_show_friend_links: boolean; aside_show_friend_links: boolean;
aside_show_showcase: boolean;
aside_widgets: AsideWidget[]; aside_widgets: AsideWidget[];
nav_show_friend_links: boolean; nav_show_friend_links: boolean;
footer_show_friend_links: boolean; footer_show_friend_links: boolean;
nav_show_showcase: boolean;
footer_show_showcase: boolean;
feed_list_style: 'title' | 'excerpt' | 'thumbnail'; feed_list_style: 'title' | 'excerpt' | 'thumbnail';
feed_sort_tabs: FeedSortTab[];
permalink_enabled: boolean; permalink_enabled: boolean;
permalink_ext: string; permalink_ext: string;
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */
monitor_pageview: boolean;
} }
export interface FriendLink { export interface FriendLink {
@@ -376,10 +419,148 @@ export interface AdminSettings {
gitea?: GiteaSyncConfig; gitea?: GiteaSyncConfig;
storage?: StorageConfig; storage?: StorageConfig;
branding?: SiteBranding; branding?: SiteBranding;
community?: CommunityConfig;
filter_words: string; filter_words: string;
filter_word_count: number; filter_word_count: number;
} }
/** 社区上报配置(hub_url / site_url 只读) */
export interface CommunityConfig {
report_enabled: boolean;
/** 只读:是否作为社区枢纽 */
hub_enabled: boolean;
/** 只读:固定为官方演示站 */
hub_url: string;
/** 只读:服务端自动推断的本站地址 */
site_url: string;
instance_id: string;
}
/** 网站监控设置 */
export interface MonitorConfig {
enabled: boolean;
retention_days: number;
access_log_retention_days: number;
exclude_rules: string[];
default_exclude_rules?: string[];
trust_proxy: boolean;
access_log_dir?: string;
ip2location_v4_path?: string;
ip2location_v6_path?: string;
ip2location_v4_available?: boolean;
ip2location_v6_available?: boolean;
geoip_available: boolean;
geoip_country_path?: string;
geoip_asn_path?: string;
geoip_country_available?: boolean;
geoip_asn_available?: boolean;
}
export interface MonitorOverview {
enabled: boolean;
pageviews: number;
visitors: number;
unique_ips: number;
traffic: number;
bots: number;
requests: number;
status_4xx: number;
status_5xx: number;
}
export interface MonitorGeoItem {
country: string;
count: number;
}
export interface MonitorRegionItem {
country: string;
region: string;
region_iso: string;
count: number;
}
export interface MonitorCityItem {
country: string;
region: string;
city: string;
count: number;
}
export interface MonitorASNItem {
asn: number;
as_org: string;
count: number;
}
export interface MonitorGeoResult {
range: string;
countries: MonitorGeoItem[];
regions: MonitorRegionItem[];
cities: MonitorCityItem[];
asns: MonitorASNItem[];
has_data: boolean;
}
export interface MonitorStatItem {
key: string;
count: number;
}
export interface MonitorRealtimePoint {
minute: string;
count: number;
bytes: number;
}
export interface MonitorRealtime {
enabled: boolean;
requests_1m: number;
traffic_1m: number;
hourly_series: MonitorRealtimePoint[];
}
export interface MonitorLogItem {
id: number;
created_at: string;
method: string;
path: string;
status: number;
bytes: number;
duration_ms: number;
ip: string;
ua: string;
referer: string;
country: string;
region?: string;
city?: string;
asn?: number;
as_org?: string;
is_bot: boolean;
}
export interface CommunityInstance {
instance_id: string;
site_url: string;
site_name: string;
version: string;
users: number;
posts: number;
first_seen_at: string;
last_seen_at: string;
online: boolean;
featured: boolean;
featured_note: string;
}
/** 公开展柜条目 */
export interface CommunityShowcaseItem {
site_url: string;
site_name: string;
version: string;
featured_note?: string;
}
export interface StorageConfig { export interface StorageConfig {
type: 'local' | 's3'; type: 'local' | 's3';
endpoint: string; endpoint: string;
@@ -541,9 +722,13 @@ export interface PrivateMessage {
to_user_id: number; to_user_id: number;
subject: string; subject: string;
content: string; content: string;
kind: 'user' | 'system' | 'reject' | 'report_result' | string; kind: 'user' | 'system' | 'reject' | 'report_result' | 'reply' | 'mention' | 'moderation' | string;
related_post_id?: number; related_post_id?: number;
related_report_id?: number; related_report_id?: number;
related_comment_id?: number;
related_floor?: number;
/** 待审目标实时状态:pending|published|rejected|deleted */
related_status?: string;
is_read: boolean; is_read: boolean;
created_at: string; created_at: string;
from_user?: User; from_user?: User;

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; import { isRouteErrorResponse, useRouteError } from 'react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad'; import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';

View File

@@ -1,5 +1,6 @@
import { import {
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode, useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo,
type ReactNode, type Ref,
} from 'react'; } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { TextSelection, NodeSelection } from '@tiptap/pm/state'; import { TextSelection, NodeSelection } from '@tiptap/pm/state';
@@ -8,7 +9,7 @@ import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import { TableKit } from '@tiptap/extension-table'; import { TableKit } from '@tiptap/extension-table';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole, List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
@@ -31,16 +32,19 @@ import {
insertMarkdownLink, insertMarkdownLink,
} from '../utils/markdownFormat'; } from '../utils/markdownFormat';
import { countWords } from '../utils/text'; import { countWords } from '../utils/text';
import { api } from '../api/client';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension'; import { MembersOnly } from './editor/MembersOnlyExtension';
import { ReplyOnly } from './editor/ReplyOnlyExtension'; import { ReplyOnly } from './editor/ReplyOnlyExtension';
import { PointsOnly } from './editor/PointsOnlyExtension'; import { PointsOnly } from './editor/PointsOnlyExtension';
import { TabIndent } from './editor/TabIndentExtension'; import { TabIndent } from './editor/TabIndentExtension';
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'; import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
import { ArticleSticker } from './editor/ArticleStickerExtension';
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension'; import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
import StickerPicker from './emoji/StickerPicker';
import type { Sticker } from '../data/stickers';
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph'; import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog'; import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog'; import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension'; import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import { import {
@@ -73,6 +77,7 @@ interface Props {
type EditorMode = 'rich' | 'markdown'; type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown'; type LinkTarget = 'rich' | 'markdown';
type ImagePickerTarget = 'rich' | 'markdown';
type CodeBlockTarget = 'rich' | 'markdown'; type CodeBlockTarget = 'rich' | 'markdown';
type TableTarget = 'rich' | 'markdown'; type TableTarget = 'rich' | 'markdown';
@@ -83,6 +88,7 @@ interface ToolBtn {
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
active?: boolean; active?: boolean;
className?: string; className?: string;
buttonRef?: Ref<HTMLButtonElement>;
action: () => void; action: () => void;
} }
@@ -115,12 +121,20 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
/** 净化编辑器 HTML,保留 members-only 自定义标签 */ /** 净化编辑器 HTML,保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
/** 判断编辑器内容是否为空 */ /** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
function isEditorEmpty(editor: Editor): boolean { function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0; if (editor.state.doc.textContent.trim().length > 0) return false;
let hasMedia = false;
editor.state.doc.descendants((node) => {
if (node.type.name === 'image' || node.type.name === 'sticker' || node.type.name === 'imageGroup') {
hasMedia = true;
return false;
}
});
return !hasMedia;
} }
/** /**
@@ -167,34 +181,6 @@ function cycleHeading(editor: Editor) {
editor.chain().focus().toggleHeading({ level: 2 }).run(); editor.chain().focus().toggleHeading({ level: 2 }).run();
} }
/** 触发图片文件选择并上传(支持多选) */
async function uploadPostImageFiles(multiple = true): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = multiple;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) {
resolve([]);
return;
}
const urls: string[] = [];
for (const file of files) {
try {
const { url } = await api.uploadPostImage(file);
urls.push(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
resolve(urls);
};
input.click();
});
}
/** 渲染工具栏按钮列表 */ /** 渲染工具栏按钮列表 */
function renderToolButtons(tools: ToolBtn[]) { function renderToolButtons(tools: ToolBtn[]) {
return tools.map((t, i) => ( return tools.map((t, i) => (
@@ -204,10 +190,12 @@ function renderToolButtons(tools: ToolBtn[]) {
) : null} ) : null}
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom"> <Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
<button <button
ref={t.buttonRef}
type="button" type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`} className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
onClick={t.action} onClick={t.action}
aria-pressed={t.active || undefined}
> >
{t.icon} {t.icon}
</button> </button>
@@ -228,7 +216,11 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [markdownSource, setMarkdownSource] = useState(''); const [markdownSource, setMarkdownSource] = useState('');
const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState(''); const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkDialogText, setLinkDialogText] = useState('');
const [linkDialogEditing, setLinkDialogEditing] = useState(false);
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich'); const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<ImagePickerTarget>('rich');
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false); const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich'); const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
const [codeBlockEditing, setCodeBlockEditing] = useState(false); const [codeBlockEditing, setCodeBlockEditing] = useState(false);
@@ -236,7 +228,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [tableDialogOpen, setTableDialogOpen] = useState(false); const [tableDialogOpen, setTableDialogOpen] = useState(false);
const [tableTarget, setTableTarget] = useState<TableTarget>('rich'); const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
const [tableEditing, setTableEditing] = useState(false); const [tableEditing, setTableEditing] = useState(false);
const [showSticker, setShowSticker] = useState(false);
const markdownRef = useRef<HTMLTextAreaElement>(null); const markdownRef = useRef<HTMLTextAreaElement>(null);
const editorBoxRef = useRef<HTMLDivElement>(null);
const stickerBtnRef = useRef<HTMLButtonElement>(null);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -262,18 +257,27 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
openOnClick: false, openOnClick: false,
autolink: true, autolink: true,
defaultProtocol: 'https', defaultProtocol: 'https',
// 新标签由全站设置在展示层处理,编辑器不写 target/rel
HTMLAttributes: {
target: null,
rel: null,
},
}), }),
ArticleImage.configure({ inline: false, allowBase64: false }), ArticleImage.configure({ inline: false, allowBase64: false }),
ArticleSticker,
ImageGroup, ImageGroup,
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') { // prosemirror 未公开 parent 字段,这里仅读父节点类型名
const parentName = (node as unknown as { parent?: { type?: { name?: string } } })
.parent?.type?.name;
if (node.type.name === 'paragraph' && parentName === 'membersOnly') {
return MEMBERS_ONLY_PLACEHOLDER; return MEMBERS_ONLY_PLACEHOLDER;
} }
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') { if (node.type.name === 'paragraph' && parentName === 'replyOnly') {
return REPLY_ONLY_PLACEHOLDER; return REPLY_ONLY_PLACEHOLDER;
} }
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') { if (node.type.name === 'paragraph' && parentName === 'pointsOnly') {
return '此处内容需积分解锁后可见…'; return '此处内容需积分解锁后可见…';
} }
return placeholder; return placeholder;
@@ -340,23 +344,42 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
placeCaretInFirstTextblock(editor); placeCaretInFirstTextblock(editor);
}, [value, editor, mode]); }, [value, editor, mode]);
// 全屏时锁定页面滚动,Esc 退出 // 全屏时锁定页面滚动;Esc 先关表情面板,再退出全屏
useEffect(() => { useEffect(() => {
if (!fullscreen) return undefined; if (!fullscreen && !showSticker) return undefined;
const prevOverflow = document.body.style.overflow; const prevOverflow = fullscreen ? document.body.style.overflow : null;
document.body.style.overflow = 'hidden'; if (fullscreen) document.body.style.overflow = 'hidden';
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFullscreen(false); if (e.key !== 'Escape') return;
if (showSticker) {
setShowSticker(false);
stickerBtnRef.current?.focus();
return;
}
if (fullscreen) setFullscreen(false);
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => { return () => {
document.body.style.overflow = prevOverflow; if (prevOverflow !== null) document.body.style.overflow = prevOverflow;
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
}; };
}, [fullscreen]); }, [fullscreen, showSticker]);
// 点击编辑器外关闭贴纸面板
useEffect(() => {
if (!showSticker) return;
const onPointer = (e: MouseEvent) => {
if (editorBoxRef.current && !editorBoxRef.current.contains(e.target as Node)) {
setShowSticker(false);
stickerBtnRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
return () => document.removeEventListener('mousedown', onPointer);
}, [showSticker]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getHTML: () => { getHTML: () => {
@@ -393,14 +416,33 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const openLinkDialog = useCallback((target: LinkTarget) => { const openLinkDialog = useCallback((target: LinkTarget) => {
if (target === 'rich') { if (target === 'rich') {
if (!editor) return; if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined; const { from, to, empty } = editor.state.selection;
setLinkDialogUrl(prev ?? ''); let text = empty ? '' : editor.state.doc.textBetween(from, to, '');
let href = '';
let editing = false;
if (editor.isActive('link')) {
const attrs = editor.getAttributes('link');
href = (attrs.href as string) || '';
editing = Boolean(href);
editor.chain().focus().extendMarkRange('link').run();
const sel = editor.state.selection;
text = editor.state.doc.textBetween(sel.from, sel.to, '') || text;
}
setLinkDialogUrl(href);
setLinkDialogText(text);
setLinkDialogEditing(editing);
} else { } else {
const textarea = markdownRef.current;
const selected = textarea
? markdownSource.slice(textarea.selectionStart, textarea.selectionEnd)
: '';
setLinkDialogUrl(''); setLinkDialogUrl('');
setLinkDialogText(selected);
setLinkDialogEditing(false);
} }
setLinkTarget(target); setLinkTarget(target);
setLinkDialogOpen(true); setLinkDialogOpen(true);
}, [editor]); }, [editor, markdownSource]);
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => { const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
setCodeBlockTarget(target); setCodeBlockTarget(target);
@@ -477,11 +519,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
} }
}, [editor]); }, [editor]);
const applyLink = useCallback((url: string) => { const applyLink = useCallback((payload: ArticleLinkConfirm) => {
const { url, text } = payload;
if (linkTarget === 'markdown') { if (linkTarget === 'markdown') {
const textarea = markdownRef.current; const textarea = markdownRef.current;
if (!textarea || !url) return; if (!textarea || !url) return;
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange); insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange, { text });
return; return;
} }
if (!editor) return; if (!editor) return;
@@ -489,7 +532,31 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().extendMarkRange('link').unsetLink().run(); editor.chain().focus().extendMarkRange('link').unsetLink().run();
return; return;
} }
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); // 新标签行为由全站 open_content_links_in_new_tab 在展示层处理
const linkAttrs = { href: url };
const { empty } = editor.state.selection;
const hasLink = editor.isActive('link');
if (!empty || hasLink) {
editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run();
const { from, to } = editor.state.selection;
const current = editor.state.doc.textBetween(from, to, '');
if (text && current !== text) {
editor.chain().focus().insertContentAt(
{ from, to },
{
type: 'text',
text,
marks: [{ type: 'link', attrs: linkAttrs }],
},
).run();
}
return;
}
editor.chain().focus().insertContent({
type: 'text',
text: text || '链接文字',
marks: [{ type: 'link', attrs: linkAttrs }],
}).run();
}, [editor, linkTarget, markdownSource, handleMarkdownChange]); }, [editor, linkTarget, markdownSource, handleMarkdownChange]);
const removeLink = useCallback(() => { const removeLink = useCallback(() => {
@@ -497,16 +564,57 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().extendMarkRange('link').unsetLink().run(); editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]); }, [editor]);
const setImage = useCallback(async () => { const insertSticker = useCallback((sticker: Sticker) => {
if (mode === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
const snippet = (sticker.type === 'text' && sticker.text)
? sticker.text
: (sticker.url ? `![${sticker.name || '表情'}](${sticker.url})` : '');
if (!snippet) return;
insertAtCursor(textarea, markdownSource, snippet, handleMarkdownChange);
setShowSticker(false);
return;
}
if (!editor) return; if (!editor) return;
const urls = await uploadPostImageFiles(true); if (sticker.type === 'text' && sticker.text) {
editor.chain().focus().insertContent(sticker.text).run();
} else if (sticker.url) {
editor.chain().focus().insertContent([
{ type: 'sticker', attrs: { src: sticker.url, alt: sticker.name } },
{ type: 'text', text: ' ' },
]).run();
}
setShowSticker(false);
}, [editor, mode, markdownSource, handleMarkdownChange]);
const openImagePicker = useCallback((target: ImagePickerTarget) => {
setImagePickerTarget(target);
setImagePickerOpen(true);
}, []);
const applyImageUrls = useCallback((urls: string[]) => {
if (!urls.length) return; if (!urls.length) return;
if (imagePickerTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
if (urls.length === 1) {
insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange);
return;
}
const layout = suggestImageGroupLayout(urls.length);
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
return;
}
if (!editor) return;
if (urls.length === 1) { if (urls.length === 1) {
editor.chain().focus().setImage({ src: urls[0] }).run(); editor.chain().focus().setImage({ src: urls[0] }).run();
return; return;
} }
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run(); editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
}, [editor]); }, [editor, imagePickerTarget, markdownSource, handleMarkdownChange]);
const setImageDisplay = useCallback((display: ImageDisplay) => { const setImageDisplay = useCallback((display: ImageDisplay) => {
if (!editor) return; if (!editor) return;
@@ -571,6 +679,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const html = sanitizeHtml(editor.getHTML()); const html = sanitizeHtml(editor.getHTML());
lastValueRef.current = html; lastValueRef.current = html;
setMarkdownSource(htmlToMarkdown(html)); setMarkdownSource(htmlToMarkdown(html));
setShowSticker(false);
setMode('markdown'); setMode('markdown');
}, [editor]); }, [editor]);
@@ -583,6 +692,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.commands.setContent(html || '', { emitUpdate: false }); editor.commands.setContent(html || '', { emitUpdate: false });
placeCaretInFirstTextblock(editor); placeCaretInFirstTextblock(editor);
} }
setShowSticker(false);
setMode('rich'); setMode('rich');
}, [editor, markdownSource, onChange]); }, [editor, markdownSource, onChange]);
@@ -596,21 +706,6 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
fn(textarea, markdownSource, handleMarkdownChange); fn(textarea, markdownSource, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]); }, [markdownSource, handleMarkdownChange]);
const insertMarkdownImage = useCallback(async () => {
const textarea = markdownRef.current;
if (!textarea) return;
const urls = await uploadPostImageFiles(true);
if (!urls.length) return;
if (urls.length === 1) {
insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange);
return;
}
const layout = suggestImageGroupLayout(urls.length);
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo( const markdownPreviewHtml = useMemo(
() => sanitizeHtml(markdownToHtml(markdownSource)), () => sanitizeHtml(markdownToHtml(markdownSource)),
[markdownSource], [markdownSource],
@@ -637,9 +732,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') }, { icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{ {
icon: <ImageIcon size={15} />, icon: <ImageIcon size={15} />,
title: '上传图片', title: '图片',
hint: '可多选;多张自动并排成图组', hint: '上传、链接或从已上传中选择',
action: setImage, action: () => openImagePicker('rich'),
}, },
{ {
icon: <Columns2 size={15} />, icon: <Columns2 size={15} />,
@@ -648,6 +743,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
active: groupActive, active: groupActive,
action: wrapSelectedAsGroup, action: wrapSelectedAsGroup,
}, },
{
icon: <span className="article-tool-btn__owo">OwO</span>,
title: '表情 OwO',
hint: '插入贴纸或颜文字',
active: showSticker,
className: 'article-tool-btn--owo',
buttonRef: stickerBtnRef,
action: () => setShowSticker(v => !v),
},
]; ];
if (editor.isActive('table')) { if (editor.isActive('table')) {
@@ -732,7 +836,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
} }
return tools; return tools;
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); }, [editor, enableContentGates, showSticker, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => { const buildMarkdownTools = useCallback((): ToolBtn[] => {
const tools: ToolBtn[] = [ const tools: ToolBtn[] = [
@@ -748,7 +852,21 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, { icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') }, { icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') }, { icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage }, {
icon: <ImageIcon size={15} />,
title: '图片',
hint: '上传、链接或从已上传中选择',
action: () => openImagePicker('markdown'),
},
{
icon: <span className="article-tool-btn__owo">OwO</span>,
title: '表情 OwO',
hint: '插入贴纸或颜文字',
active: showSticker,
className: 'article-tool-btn--owo',
buttonRef: stickerBtnRef,
action: () => setShowSticker(v => !v),
},
]; ];
if (enableContentGates) { if (enableContentGates) {
tools.push( tools.push(
@@ -776,7 +894,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
); );
} }
return tools; return tools;
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]); }, [enableContentGates, showSticker, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools(); const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown' const words = mode === 'markdown'
@@ -784,12 +902,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
: (editor ? countWords(editor.getText()) : 0); : (editor ? countWords(editor.getText()) : 0);
return ( return (
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}> <div
ref={editorBoxRef}
className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}
>
<div className="article-editor-bar"> <div className="article-editor-bar">
<div className="article-editor-tools"> <div className="article-editor-tools">
{renderToolButtons(tools)} {renderToolButtons(tools)}
</div> </div>
</div> </div>
{showSticker && <StickerPicker onSelect={insertSticker} />}
<div className="article-editor-body"> <div className="article-editor-body">
{mode === 'rich' ? ( {mode === 'rich' ? (
@@ -897,8 +1019,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
open={linkDialogOpen} open={linkDialogOpen}
onOpenChange={setLinkDialogOpen} onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl} initialUrl={linkDialogUrl}
initialText={linkDialogText}
editing={linkDialogEditing}
onConfirm={applyLink} onConfirm={applyLink}
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined} onRemove={linkTarget === 'rich' && linkDialogEditing ? removeLink : undefined}
/>
<ArticleImagePickerDialog
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
onInsert={applyImageUrls}
/> />
<ArticleCodeBlockDialog <ArticleCodeBlockDialog
open={codeBlockDialogOpen} open={codeBlockDialogOpen}

View File

@@ -1,6 +1,5 @@
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react'; import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { Skeleton } from '@/components/ui/skeleton';
import { useCheckIn } from '../hooks/useCheckIn'; import { useCheckIn } from '../hooks/useCheckIn';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
@@ -8,8 +7,14 @@ import { loginPath } from '../utils/authRedirect';
/** 右侧栏首块底部:每日签到 */ /** 右侧栏首块底部:每日签到 */
export default function AsideCheckInStrip() { export default function AsideCheckInStrip() {
const nav = useNavigate(); const nav = useNavigate();
const { user } = useAuth(); const { user, loading: authLoading } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn(); const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
// 鉴权未完成且无种子:空白,避免访客签到卡闪一下再消失
// 有 SSR boot 时 loading 一开始就是 false
if (authLoading) {
return null;
}
if (!user) { if (!user) {
return ( return (
@@ -37,17 +42,14 @@ export default function AsideCheckInStrip() {
); );
} }
if (loading && !status) { // 登录用户:status 未到齐前不渲染最终态
return ( if (loading || !status) {
<div className="widget-checkin" aria-busy="true" aria-label="签到加载中"> return null;
<Skeleton className="widget-checkin-skeleton" />
</div>
);
} }
const checkedIn = !!status?.checked_in; const checkedIn = !!status.checked_in;
const streak = status?.streak ?? 0; const streak = status.streak ?? 0;
const todayPoints = status?.today_points ?? 5; const todayPoints = status.today_points ?? 5;
const meta = checkedIn const meta = checkedIn
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`) ? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router';
import { ArrowUp, MessageSquare } from 'lucide-react'; import { ArrowUp, MessageSquare } from 'lucide-react';
/** 滚动超过该距离后显示按钮 */ /** 滚动超过该距离后显示按钮 */
@@ -10,7 +10,8 @@ const BIND_RETRY_MS = 50;
/** /**
* 定位当前真正滚动的容器。 * 定位当前真正滚动的容器。
* 前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose * Feed 文档滚动模式(.app-shell--doc-scroll):滚动根为窗口(document.scrollingElement)。
* 其余前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose
* 后台:.admin-main * 后台:.admin-main
*/ */
function pickScrollEl(scope: ParentNode): HTMLElement | null { function pickScrollEl(scope: ParentNode): HTMLElement | null {
@@ -52,7 +53,7 @@ export default function BackToTop() {
let cancelled = false; let cancelled = false;
let attempts = 0; let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined; let retryTimer: ReturnType<typeof setTimeout> | undefined;
let bound: HTMLElement | null = null; let bound: EventTarget | null = null;
const onScroll = () => { const onScroll = () => {
if (!cancelled) syncVisible(); if (!cancelled) syncVisible();
@@ -80,6 +81,15 @@ export default function BackToTop() {
const tryBind = () => { const tryBind = () => {
if (cancelled) return; if (cancelled) return;
// Feed 文档滚动模式:滚动根是窗口,scroll 事件落在 document 上
if (document.querySelector('.app-shell--doc-scroll')) {
unbind();
bound = document;
scrollElRef.current = document.scrollingElement as HTMLElement | null;
document.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return;
}
const scope = findScrollScope(); const scope = findScrollScope();
if (!scope) { if (!scope) {
setVisible(false); setVisible(false);

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,15 +1,19 @@
import { useNavigate } from 'react-router-dom'; import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { api } from '../api/client'; import { api } from '../api/client';
import { renderCommentContent } from '../utils/content'; import { renderCommentContent } from '../utils/content';
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
import { notify } from '@/lib/notify';
interface Props { interface Props {
content: string; content: string;
} }
/** 渲染评论正文(支持正文内 @ 高亮与点击跳转) */ /** 渲染评论正文(支持正文内 @ 高亮、代码块阅读态与点击跳转) */
export default function CommentContent({ content }: Props) { export default function CommentContent({ content }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const html = useMemo(() => renderCommentContent(content), [content]);
const openMention = async (name: string) => { const openMention = async (name: string) => {
try { try {
@@ -26,17 +30,29 @@ export default function CommentContent({ content }: Props) {
} }
}; };
const onClick = useCallback(async (e: React.MouseEvent) => {
try {
if (await handleMdCodeBlockUiClick(e.target)) {
e.preventDefault();
return;
}
} catch {
notify.error('复制失败');
return;
}
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
if (!el) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}, [nav]);
return ( return (
<div <div
className="floor-body" className="floor-body post-detail-content"
onClick={(e) => { onClick={(e) => { void onClick(e); }}
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
if (!el) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return; if (e.key !== 'Enter' && e.key !== ' ') return;
const el = e.target as HTMLElement; const el = e.target as HTMLElement;
@@ -46,9 +62,7 @@ export default function CommentContent({ content }: Props) {
e.preventDefault(); e.preventDefault();
void openMention(name); void openMention(name);
}} }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{ __html: html }}
__html: renderCommentContent(content),
}}
/> />
); );
} }

View File

@@ -6,17 +6,17 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Bold, Italic, Underline as UnderlineIcon, Strikethrough,
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon, List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
} from 'lucide-react'; } from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent'; import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension'; import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog'; import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleImage } from './editor/ArticleImageExtension'; import { ArticleImage } from './editor/ArticleImageExtension';
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
import { TabIndent } from './editor/TabIndentExtension'; import { TabIndent } from './editor/TabIndentExtension';
import type { CodeBlockInsertOptions } from '../utils/codeBlockOptions'; import type { CodeBlockInsertOptions } from '../utils/codeBlockOptions';
import { Tooltip } from './ui/Tooltip'; import { Tooltip } from './ui/Tooltip';
@@ -36,7 +36,7 @@ interface Props {
} }
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
function isEditorEmpty(editor: Editor): boolean { function isEditorEmpty(editor: Editor): boolean {
@@ -49,28 +49,6 @@ function isEditorEmpty(editor: Editor): boolean {
return !hasImage; return !hasImage;
} }
/** 触发图片文件选择并上传 */
async function uploadImageFiles(): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = false;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) { resolve([]); return; }
try {
const { url } = await api.uploadPostImage(files[0]);
resolve([url]);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve([]);
}
};
input.click();
});
}
const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEditor( const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEditor(
{ value, onChange, placeholder = '说点什么吧…' }, { value, onChange, placeholder = '说点什么吧…' },
ref, ref,
@@ -79,6 +57,11 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
const lastValueRef = useRef(value); const lastValueRef = useRef(value);
const [, setTick] = useState(0); const [, setTick] = useState(0);
const [showSticker, setShowSticker] = useState(false); const [showSticker, setShowSticker] = useState(false);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkDialogText, setLinkDialogText] = useState('');
const [linkDialogEditing, setLinkDialogEditing] = useState(false);
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false); const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockEditing, setCodeBlockEditing] = useState(false); const [codeBlockEditing, setCodeBlockEditing] = useState(false);
const [codeBlockInitial, setCodeBlockInitial] = useState<CodeBlockInsertOptions | null>(null); const [codeBlockInitial, setCodeBlockInitial] = useState<CodeBlockInsertOptions | null>(null);
@@ -99,6 +82,11 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
openOnClick: false, openOnClick: false,
autolink: true, autolink: true,
defaultProtocol: 'https', defaultProtocol: 'https',
// 新标签由全站设置在展示层处理,编辑器不写 target/rel
HTMLAttributes: {
target: null,
rel: null,
},
}), }),
ArticleImage.configure({ inline: true, allowBase64: true }), ArticleImage.configure({ inline: true, allowBase64: true }),
Placeholder.configure({ placeholder }), Placeholder.configure({ placeholder }),
@@ -160,7 +148,9 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value, getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value,
isEmpty: () => editor ? isEditorEmpty(editor) : !value.trim(), isEmpty: () => editor ? isEditorEmpty(editor) : !value.trim(),
focus: (options?: { preventScroll?: boolean }) => { editor?.commands.focus(options); }, focus: (options?: { preventScroll?: boolean }) => {
editor?.commands.focus(undefined, { scrollIntoView: !options?.preventScroll });
},
}), [editor, value]); }), [editor, value]);
const insertSticker = useCallback((sticker: Sticker) => { const insertSticker = useCallback((sticker: Sticker) => {
@@ -179,11 +169,14 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
setShowSticker(false); setShowSticker(false);
}, [editor]); }, [editor]);
const setImage = useCallback(async () => { const applyImageUrls = useCallback((urls: string[]) => {
if (!editor) return; if (!editor || !urls.length) return;
const urls = await uploadImageFiles(); // 评论为 inline 图,无图组:逐张插入并跟空格,便于光标落在右侧
if (!urls.length) return; const nodes = urls.flatMap(src => [
editor.chain().focus().setImage({ src: urls[0] }).run(); { type: 'image' as const, attrs: { src } },
{ type: 'text' as const, text: ' ' },
]);
editor.chain().focus().insertContent(nodes).run();
}, [editor]); }, [editor]);
const openCodeBlockDialog = useCallback(() => { const openCodeBlockDialog = useCallback(() => {
@@ -213,23 +206,70 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
}).run(); }).run();
}, [editor]); }, [editor]);
const setLink = useCallback(() => { const openLinkDialog = useCallback(() => {
if (!editor) return; if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined; const { from, to, empty } = editor.state.selection;
const url = window.prompt('链接地址', prev ?? 'https://'); let text = empty ? '' : editor.state.doc.textBetween(from, to, '');
if (url === null) return; let href = '';
let editing = false;
if (editor.isActive('link')) {
const attrs = editor.getAttributes('link');
href = (attrs.href as string) || '';
editing = Boolean(href);
editor.chain().focus().extendMarkRange('link').run();
const sel = editor.state.selection;
text = editor.state.doc.textBetween(sel.from, sel.to, '') || text;
}
setLinkDialogUrl(href);
setLinkDialogText(text);
setLinkDialogEditing(editing);
setLinkDialogOpen(true);
}, [editor]);
const applyLink = useCallback((payload: ArticleLinkConfirm) => {
if (!editor) return;
const { url, text } = payload;
if (!url) { if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run(); editor.chain().focus().extendMarkRange('link').unsetLink().run();
return; return;
} }
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); // 新标签行为由全站 open_content_links_in_new_tab 在展示层处理
const linkAttrs = { href: url };
const { empty } = editor.state.selection;
const hasLink = editor.isActive('link');
if (!empty || hasLink) {
editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run();
const { from, to } = editor.state.selection;
const current = editor.state.doc.textBetween(from, to, '');
if (text && current !== text) {
editor.chain().focus().insertContentAt(
{ from, to },
{
type: 'text',
text,
marks: [{ type: 'link', attrs: linkAttrs }],
},
).run();
}
return;
}
editor.chain().focus().insertContent({
type: 'text',
text: text || '链接文字',
marks: [{ type: 'link', attrs: linkAttrs }],
}).run();
}, [editor]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]); }, [editor]);
if (!editor) { if (!editor) {
return <div className="comment-editor"><div className="article-editor-bar" /><div className="article-editor-body" /></div>; return <div className="comment-editor"><div className="article-editor-bar" /><div className="article-editor-body" /></div>;
} }
const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void; className?: string }[] = [ const tools: { icon: React.ReactNode; title: string; hint?: string; active?: boolean; action: () => void; className?: string }[] = [
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() }, { 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: <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: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
@@ -237,8 +277,13 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().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: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog }, { icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: setLink }, { icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: openLinkDialog },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage }, {
icon: <ImageIcon size={15} />,
title: '图片',
hint: '上传、链接或从已上传中选择',
action: () => setImagePickerOpen(true),
},
{ icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' }, { icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' },
]; ];
@@ -247,14 +292,14 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
<div className="article-editor-bar"> <div className="article-editor-bar">
<div className="article-editor-tools"> <div className="article-editor-tools">
{tools.map((t, i) => ( {tools.map((t, i) => (
<Tooltip key={i} content={t.title} side="bottom"> <Tooltip key={i} content={t.title} hint={t.hint} side="bottom">
<button <button
ref={i === tools.length - 1 ? stickerBtnRef : undefined} ref={i === tools.length - 1 ? stickerBtnRef : undefined}
type="button" type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`} className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
onClick={t.action} onClick={t.action}
aria-label={t.title} aria-label={t.hint ? `${t.title},${t.hint}` : t.title}
> >
{t.icon} {t.icon}
</button> </button>
@@ -268,6 +313,20 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
</div> </div>
</div> </div>
{showSticker && <StickerPicker onSelect={insertSticker} />} {showSticker && <StickerPicker onSelect={insertSticker} />}
<ArticleLinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl}
initialText={linkDialogText}
editing={linkDialogEditing}
onConfirm={applyLink}
onRemove={linkDialogEditing ? removeLink : undefined}
/>
<ArticleImagePickerDialog
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
onInsert={applyImageUrls}
/>
<ArticleCodeBlockDialog <ArticleCodeBlockDialog
open={codeBlockDialogOpen} open={codeBlockDialogOpen}
onOpenChange={setCodeBlockDialogOpen} onOpenChange={setCodeBlockDialogOpen}

View File

@@ -18,7 +18,6 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { import {
Dialog, Dialog,
@@ -32,6 +31,8 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -144,6 +145,11 @@ function CommentItem({
&& !!onApprove; && !!onApprove;
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at); const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser); const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
const showHistory = isAdmin && showEdited;
const showManageMenu = !hidden && !isEditing && (
canApprove || canEdit || showHistory || canDelete || canReport
);
const showManageGroup = canApprove || canEdit || showHistory || canDelete;
const [editText, setEditText] = useState(c.content); const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -152,6 +158,7 @@ function CommentItem({
const [liked, setLiked] = useState(!!c.liked); const [liked, setLiked] = useState(!!c.liked);
const [likeCount, setLikeCount] = useState(c.like_count ?? 0); const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
const [revOpen, setRevOpen] = useState(false); const [revOpen, setRevOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState<ReportReason>('spam'); const [reportReason, setReportReason] = useState<ReportReason>('spam');
const [reportDetail, setReportDetail] = useState(''); const [reportDetail, setReportDetail] = useState('');
@@ -333,24 +340,6 @@ function CommentItem({
{c.reply_target && ( {c.reply_target && (
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span> <span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
)} )}
{!hidden && !isEditing && canApprove && (
<button
type="button"
className="waline-comment-reply-btn waline-comment-approve-btn"
disabled={approving}
onClick={async () => {
setApproving(true);
try {
await onApprove?.(c);
} finally {
setApproving(false);
}
}}
>
<Check size={14} />
{approving ? '通过中…' : '通过'}
</button>
)}
{!hidden && !isEditing && !!renderReplyBox && ( {!hidden && !isEditing && !!renderReplyBox && (
isReplying ? ( isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}> <button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
@@ -366,12 +355,6 @@ function CommentItem({
</Tooltip> </Tooltip>
) )
)} )}
{!hidden && !isEditing && canEdit && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
编辑
</button>
)}
{bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId {bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId
&& c.status === 'published' && !hidden && ( && c.status === 'published' && !hidden && (
<button <button
@@ -383,46 +366,7 @@ function CommentItem({
采纳 采纳
</button> </button>
)} )}
{!hidden && !isEditing && isAdmin && showEdited && ( {showManageMenu && (
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
<History size={14} />
编辑记录
</button>
)}
{!hidden && !isEditing && canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
<Trash2 size={14} />
删除
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
<AlertDialogDescription>
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeleting(true);
try {
await onDelete(c);
} finally {
setDeleting(false);
}
}}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{canReport && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@@ -433,19 +377,98 @@ function CommentItem({
<MoreHorizontal size={14} /> <MoreHorizontal size={14} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="report-more-menu"> <DropdownMenuContent align="end" className="comment-manage-menu">
<DropdownMenuItem {showManageGroup && (
className="report-more-menu__item" <>
onSelect={openReport} <DropdownMenuLabel>管理</DropdownMenuLabel>
> {canApprove && (
<Flag size={14} /> <DropdownMenuItem
举报 disabled={approving}
</DropdownMenuItem> onSelect={() => {
void (async () => {
setApproving(true);
try {
await onApprove?.(c);
} finally {
setApproving(false);
}
})();
}}
>
<Check size={14} aria-hidden />
{approving ? '通过中…' : '通过审核'}
</DropdownMenuItem>
)}
{canEdit && (
<DropdownMenuItem onSelect={() => onStartEdit(c)}>
<Pencil size={14} aria-hidden />
编辑
</DropdownMenuItem>
)}
{showHistory && (
<DropdownMenuItem onSelect={() => setRevOpen(true)}>
<History size={14} aria-hidden />
编辑记录
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={deleting}
onSelect={() => setDeleteOpen(true)}
>
<Trash2 size={14} aria-hidden />
删除
</DropdownMenuItem>
)}
{canReport && <DropdownMenuSeparator />}
</>
)}
{canReport && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={openReport}
>
<Flag size={14} aria-hidden />
举报
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
<AlertDialogDescription>
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={(e) => {
e.preventDefault();
void (async () => {
setDeleting(true);
try {
await onDelete(c);
setDeleteOpen(false);
} finally {
setDeleting(false);
}
})();
}}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isAdmin && ( {isAdmin && (
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} /> <CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
)} )}

View File

@@ -1,4 +1,4 @@
import { Sparkles } from 'lucide-react'; import { BadgeCheck } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -6,13 +6,13 @@ interface Props {
size?: number; size?: number;
} }
/** 精华帖标识 */ /** 推荐帖标识 */
export default function FeaturedIcon({ className, size = 16 }: Props) { export default function FeaturedIcon({ className, size = 16 }: Props) {
return ( return (
<Sparkles <BadgeCheck
className={cn('post-featured-icon', className)} className={cn('post-featured-icon', className)}
size={size} size={size}
aria-label="精华" aria-label="推荐"
role="img" role="img"
/> />
); );

View File

@@ -1,4 +1,4 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { navigateFeed } from '../utils/feedCache'; import { navigateFeed } from '../utils/feedCache';
interface Props { interface Props {

View File

@@ -1,29 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
import { useForumLimits } from '../hooks/useForumLimits';
/** 首页 Feed 初始骨架(排序栏 + 列表) */
export default function FeedPageSkeleton() {
const { limits } = useForumLimits();
return (
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
</div>
<div className="post-list-scroll">
<PostListSkeleton listStyle={limits.feed_list_style ?? 'title'} />
</div>
</div>
</div>
);
}

View File

@@ -1,39 +1,56 @@
import { useRef } from 'react'; import { useMemo, useRef } from 'react';
import { boardPath, type PermalinkOpts } from '../utils/permalink'; import { useParams, useSearchParams } from 'react-router';
import { getCachedForumLimits } from '../hooks/useForumLimits'; import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
import { Clock, MessageCircle, Flame } from 'lucide-react'; import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y'; import { moveTabIndex } from '../hooks/useOverlayA11y';
import {
enabledFeedSortTabs,
getDefaultFeedSort,
normalizeFeedSortTabs,
} from '../utils/feedSortTabs';
import type { FeedSortTab } from '../api/types';
export type FeedSort = 'latest' | 'reply' | 'hot'; export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: { const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
key: FeedSort; reply: { hint: '按最近活动(发帖或评论)', icon: MessageCircle },
label: string; latest: { hint: '按发帖时间', icon: Clock },
hint: string; hot: { hint: '仅展示推荐帖', icon: BadgeCheck },
icon: typeof Clock; };
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
interface Props { interface Props {
value: FeedSort; value: FeedSort;
onChange: (sort: FeedSort) => void; onChange: (sort: FeedSort) => void;
postTotal?: number; postTotal?: number;
/** 正在加载、尚未提交到画面的目标排序 */
pendingValue?: FeedSort | null;
} }
export function parseFeedSort(raw: string | null): FeedSort { /** 当前配置下的默认排序(读缓存,供非 hook 路径) */
if (raw === 'reply' || raw === 'hot') return raw; export function getDefaultFeedSortFromCache(): FeedSort {
return 'latest'; return getDefaultFeedSort(getCachedForumLimits().feed_sort_tabs);
}
/** 解析 URL sort;未启用或不合法时回落默认 */
export function parseFeedSort(raw: string | null, tabs?: FeedSortTab[] | null): FeedSort {
const list = tabs ?? getCachedForumLimits().feed_sort_tabs;
const def = getDefaultFeedSort(list);
if (raw === 'latest' || raw === 'hot' || raw === 'reply') {
const enabled = enabledFeedSortTabs(list).some(t => t.id === raw);
if (enabled) return raw;
}
return def;
} }
export function buildHomeUrl( export function buildHomeUrl(
boardId: number, boardId: number,
sort: FeedSort = 'latest', sort?: FeedSort,
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts }, opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
) { ) {
const def = getDefaultFeedSortFromCache();
const effective = sort ?? def;
const p = new URLSearchParams(); const p = new URLSearchParams();
const tag = opts?.tag?.trim(); const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim(); const keyword = opts?.keyword?.trim();
@@ -47,7 +64,8 @@ export function buildHomeUrl(
} else if (author) { } else if (author) {
p.set('author', author); p.set('author', author);
} }
if (sort !== 'latest') p.set('sort', sort); // 默认排序不写进 URL
if (effective !== def) p.set('sort', effective);
const qs = p.toString(); const qs = p.toString();
if (boardId) { if (boardId) {
@@ -57,25 +75,65 @@ export function buildHomeUrl(
return qs ? `/?${qs}` : '/'; return qs ? `/?${qs}` : '/';
} }
export function feedSortLabel(sort: FeedSort): string { export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表'; const list = normalizeFeedSortTabs(tabs ?? getCachedForumLimits().feed_sort_tabs);
return list.find(t => t.id === sort)?.label ?? '帖子列表';
} }
export default function FeedSortBar({ value, onChange, postTotal }: Props) { export default function FeedSortBar({
value,
onChange,
postTotal,
pendingValue,
}: Props) {
const { limits } = useForumLimits();
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
// 与 SSR href / HomePage 一致:从路由与查询串解析板块与筛选
const boardId = boardRouteId
? (() => {
const id = parsePermalinkID(boardRouteId);
return Number.isFinite(id) && id > 0 ? id : 0;
})()
: (Number(params.get('board')) || 0);
const keyword = params.get('keyword') || '';
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const options = useMemo(
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({
key: t.id as FeedSort,
label: t.label,
hint: SORT_META[t.id]?.hint ?? '',
icon: SORT_META[t.id]?.icon ?? Clock,
href: buildHomeUrl(boardId, t.id as FeedSort, {
keyword,
tag,
author,
titleOnly,
permalink: limits,
}),
})),
[limits, boardId, keyword, tag, author, titleOnly],
);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value)); const activeIndex = Math.max(0, options.findIndex(o => o.key === value));
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length); const next = moveTabIndex(e.key, activeIndex, options.length);
if (next == null) return; if (next == null) return;
e.preventDefault(); e.preventDefault();
onChange(SORT_OPTIONS[next].key); onChange(options[next].key);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]'); const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus(); tabs?.[next]?.focus();
}); });
}; };
if (options.length === 0) return null;
return ( return (
<div className="feed-toolbar"> <div className="feed-toolbar">
<div <div
@@ -85,21 +143,30 @@ export default function FeedSortBar({ value, onChange, postTotal }: Props) {
aria-label="帖子排序" aria-label="帖子排序"
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => ( {options.map(({ key, label, hint, icon: Icon, href }, i) => {
<button const pending = pendingValue === key;
key={key} return (
type="button" <a
role="tab" key={key}
tabIndex={activeIndex === i ? 0 : -1} href={href}
aria-selected={value === key} role="tab"
title={`${label} · ${hint}`} tabIndex={activeIndex === i ? 0 : -1}
className={cn('feed-sort-tab', value === key && 'active')} aria-selected={value === key}
onClick={() => onChange(key)} aria-busy={pending || undefined}
> title={`${label} · ${hint}`}
<Icon aria-hidden /> className={cn('feed-sort-tab', value === key && 'active', pending && 'is-pending')}
<span className="feed-sort-tab__label">{label}</span> onClick={(e) => {
</button> e.preventDefault();
))} onChange(key);
}}
>
{pending
? <Loader2 className="feed-sort-tab__spin" aria-hidden />
: <Icon aria-hidden />}
<span className="feed-sort-tab__label">{label}</span>
</a>
);
})}
</div> </div>
{postTotal != null && ( {postTotal != null && (
<span className="feed-toolbar__count">共 {postTotal} 条</span> <span className="feed-toolbar__count">共 {postTotal} 条</span>

View File

@@ -6,7 +6,7 @@ type PageLoaderProps = {
fullScreen?: boolean; fullScreen?: boolean;
}; };
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */ /** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */
export default function PageLoader({ fullScreen = false }: PageLoaderProps) { export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
return ( return (
<div <div

View File

@@ -0,0 +1,227 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { Sticker } from '../data/stickers';
export type PmComposerInputHandle = {
insertSticker: (sticker: Sticker) => void;
insertText: (text: string) => void;
focus: () => void;
};
type Props = {
value: string;
onChange: (next: string) => void;
placeholder?: string;
maxLength?: number;
disabled?: boolean;
onSubmit?: () => void;
};
/** 站点内置贴纸路径(评论/私信共用) */
export function isPmStickerSrc(src: string) {
return src.includes('/stickers/');
}
function escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function draftToHtml(text: string): string {
if (!text) return '';
const parts = text.split(/(!\[[^\]]*]\([^)]+\))/g);
return parts.map((part) => {
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
if (m) {
const alt = escapeHtml(m[1] || '表情');
const src = escapeHtml(m[2]);
const cls = isPmStickerSrc(m[2]) ? 'pm-composer__sticker' : 'pm-composer__inline-img';
return `<img class="${cls}" src="${src}" alt="${alt}" draggable="false">`;
}
return escapeHtml(part).replace(/\n/g, '<br>');
}).join('');
}
function serializeComposer(root: HTMLElement): string {
let out = '';
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
out += node.textContent || '';
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
const tag = el.tagName;
if (tag === 'IMG') {
const src = el.getAttribute('src') || '';
const alt = el.getAttribute('alt') || '表情';
out += `![${alt}](${src})`;
return;
}
if (tag === 'BR') {
out += '\n';
return;
}
if (tag === 'DIV' || tag === 'P') {
if (out.length > 0 && !out.endsWith('\n')) out += '\n';
el.childNodes.forEach(walk);
return;
}
el.childNodes.forEach(walk);
};
root.childNodes.forEach(walk);
return out.replace(/\n$/, '');
}
function placeCaretAfter(node: Node) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
/** 私信输入框:可内联插入贴纸(与评论编辑器一致),序列化为 markdown 图片语法 */
const PmComposerInput = forwardRef<PmComposerInputHandle, Props>(function PmComposerInput(
{ value, onChange, placeholder, maxLength = 4000, disabled, onSubmit },
ref,
) {
const elRef = useRef<HTMLDivElement>(null);
const savedRangeRef = useRef<Range | null>(null);
const valueRef = useRef(value);
valueRef.current = value;
const emit = useCallback(() => {
const el = elRef.current;
if (!el) return;
const next = serializeComposer(el);
if (maxLength && next.length > maxLength) {
el.innerHTML = draftToHtml(valueRef.current);
return;
}
el.dataset.empty = next ? 'false' : 'true';
if (next !== valueRef.current) onChange(next);
}, [maxLength, onChange]);
const restoreRange = useCallback(() => {
const el = elRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const saved = savedRangeRef.current;
if (saved && el.contains(saved.startContainer)) {
sel.removeAllRanges();
sel.addRange(saved);
return;
}
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}, []);
const saveRange = useCallback(() => {
const el = elRef.current;
const sel = window.getSelection();
if (!el || !sel || sel.rangeCount === 0) return;
if (!el.contains(sel.anchorNode)) return;
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
}, []);
const insertNodes = useCallback((nodes: Node[]) => {
const el = elRef.current;
if (!el || !nodes.length) return;
restoreRange();
const sel = window.getSelection();
if (!sel) return;
const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : document.createRange();
range.deleteContents();
const frag = document.createDocumentFragment();
nodes.forEach((n) => frag.appendChild(n));
const last = nodes[nodes.length - 1];
range.insertNode(frag);
placeCaretAfter(last);
saveRange();
emit();
}, [emit, restoreRange, saveRange]);
useImperativeHandle(ref, () => ({
insertSticker(sticker) {
if (!sticker.url) return;
const img = document.createElement('img');
img.className = 'pm-composer__sticker';
img.src = sticker.url;
img.alt = sticker.name || '表情';
img.draggable = false;
// 尾随空格,光标可停在贴纸右侧(对齐评论编辑器)
insertNodes([img, document.createTextNode(' ')]);
},
insertText(text) {
if (!text) return;
insertNodes([document.createTextNode(text)]);
},
focus() {
restoreRange();
},
}), [insertNodes, restoreRange]);
// 外部改 value(切会话 / 发送清空)时回填;输入过程中 serialize 与 value 一致则不重绘
useEffect(() => {
const el = elRef.current;
if (!el) return;
if (serializeComposer(el) === value) {
el.dataset.empty = value ? 'false' : 'true';
return;
}
el.innerHTML = draftToHtml(value);
el.dataset.empty = value ? 'false' : 'true';
}, [value]);
useEffect(() => {
const onSel = () => saveRange();
document.addEventListener('selectionchange', onSel);
return () => document.removeEventListener('selectionchange', onSel);
}, [saveRange]);
return (
<div
ref={elRef}
className="pm-composer__input"
role="textbox"
aria-multiline="true"
aria-label="私信内容"
contentEditable={!disabled}
data-placeholder={placeholder || ''}
data-empty={value ? 'false' : 'true'}
suppressContentEditableWarning
onInput={emit}
onBlur={saveRange}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (text) insertNodes([document.createTextNode(text)]);
}}
onDrop={(e) => {
// 禁止把文件拖成「大图附件」;贴纸只走选择器
if (e.dataTransfer?.files?.length) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit?.();
}
}}
/>
);
});
export default PmComposerInput;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react'; import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -9,13 +9,15 @@ import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { formatTime } from '../utils/content'; import { formatTime } from '../utils/content';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
import ComposeMessageDialog from './ComposeMessageDialog';
import UserLink from './UserLink'; import UserLink from './UserLink';
interface Props { interface Props {
author?: User | null; author?: User | null;
publishedAt?: string; publishedAt?: string;
viewCount?: number; viewCount?: number;
/** SSR/预热快照携带的作者主页资料与统计:首帧直接渲染,避免挂载后再拉 /api/users/:id */
initialProfile?: UserPublic | null;
initialStats?: UserActivityStats | null;
} }
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */ /** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
@@ -23,12 +25,13 @@ export default function PostAuthorCard({
author, author,
publishedAt, publishedAt,
viewCount, viewCount,
initialProfile = null,
initialStats = null,
}: Props) { }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const { user: me } = useAuth(); const { user: me } = useAuth();
const [profile, setProfile] = useState<UserPublic | null>(null); const [profile, setProfile] = useState<UserPublic | null>(initialProfile);
const [stats, setStats] = useState<UserActivityStats | null>(null); const [stats, setStats] = useState<UserActivityStats | null>(initialStats);
const [msgOpen, setMsgOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!author?.id) { if (!author?.id) {
@@ -36,6 +39,12 @@ export default function PostAuthorCard({
setStats(null); setStats(null);
return; return;
} }
// 快照(SSR / 客户端预热 / 换帖)已带完整资料:同步即可,不再发请求
if (initialProfile && initialStats) {
setProfile(initialProfile);
setStats(initialStats);
return;
}
let cancelled = false; let cancelled = false;
api.userProfile(author.id) api.userProfile(author.id)
.then((r) => { .then((r) => {
@@ -50,7 +59,7 @@ export default function PostAuthorCard({
setStats(null); setStats(null);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [author?.id]); }, [author?.id, initialProfile, initialStats]);
if (!author?.id) { if (!author?.id) {
return ( return (
@@ -78,7 +87,7 @@ export default function PostAuthorCard({
nav(loginPath(profileHref)); nav(loginPath(profileHref));
return; return;
} }
setMsgOpen(true); nav(`/messages?peer=${author.id}`);
}; };
return ( return (
@@ -159,16 +168,6 @@ export default function PostAuthorCard({
</Button> </Button>
</div> </div>
</div> </div>
{!isSelf && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={author.id}
toNickname={nick}
onSent={() => nav(`/messages?peer=${author.id}`)}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useMemo, useCallback, useEffect, useState } from 'react'; import { useMemo, useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router';
import { renderPostContentHtml } from '../utils/postContent'; import { renderPostContentHtml } from '../utils/postContent';
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
import { loginPath, registerPath } from '../utils/authRedirect'; import { loginPath, registerPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
@@ -12,8 +12,6 @@ interface Props {
html: string; html: string;
isLoggedIn: boolean; isLoggedIn: boolean;
className?: string; className?: string;
/** 正文标题树变化时回调(用于侧栏目录) */
onHeadingsChange?: (headings: PostHeading[]) => void;
/** 点击「回复可见」门控的「去回复」 */ /** 点击「回复可见」门控的「去回复」 */
onRequestReply?: () => void; onRequestReply?: () => void;
/** 积分解锁成功后刷新正文 */ /** 积分解锁成功后刷新正文 */
@@ -26,7 +24,6 @@ export default function PostContent({
html, html,
isLoggedIn, isLoggedIn,
className = 'post-detail-content', className = 'post-detail-content',
onHeadingsChange,
onRequestReply, onRequestReply,
onUnlocked, onUnlocked,
postId: postIdProp, postId: postIdProp,
@@ -39,19 +36,12 @@ export default function PostContent({
const [lightboxAlt, setLightboxAlt] = useState(''); const [lightboxAlt, setLightboxAlt] = useState('');
const [unlocking, setUnlocking] = useState(false); const [unlocking, setUnlocking] = useState(false);
const prepared = useMemo(() => { const preparedHtml = useMemo(
const rendered = renderPostContentHtml(html, isLoggedIn, { () => renderPostContentHtml(html, isLoggedIn, {
openLinksInNewTab: limits.open_content_links_in_new_tab, openLinksInNewTab: limits.open_content_links_in_new_tab,
}); }),
return { [html, isLoggedIn, limits.open_content_links_in_new_tab],
html: rendered, );
headings: extractHeadingsFromHtml(rendered),
};
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
useEffect(() => {
onHeadingsChange?.(prepared.headings);
}, [prepared.headings, onHeadingsChange]);
const openLightbox = useCallback((img: HTMLImageElement) => { const openLightbox = useCallback((img: HTMLImageElement) => {
const full = img.getAttribute('data-full') || img.currentSrc || img.src; const full = img.getAttribute('data-full') || img.currentSrc || img.src;
@@ -116,40 +106,12 @@ export default function PostContent({
} }
return; return;
} }
const foldBtn = target.closest<HTMLElement>('[data-code-fold]'); try {
if (foldBtn) { if (await handleMdCodeBlockUiClick(target)) {
e.preventDefault(); e.preventDefault();
const block = foldBtn.closest('.md-codeblock');
if (!block) return;
const collapsed = block.classList.toggle('md-codeblock--collapsed');
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|| block.querySelectorAll('.md-code-line').length
|| 1;
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
else block.classList.remove('md-codeblock--short');
foldBtn.textContent = collapsed ? '展开' : '收起';
return;
}
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) {
e.preventDefault();
const block = copyBtn.closest('.md-codeblock');
const bodies = block?.querySelectorAll('.md-code-line__body');
const text = bodies && bodies.length
? [...bodies].map(el => el.textContent ?? '').join('\n')
: (block?.querySelector('pre')?.textContent ?? '');
try {
await navigator.clipboard.writeText(text);
const prev = copyBtn.textContent;
copyBtn.textContent = '已复制';
copyBtn.classList.add('is-copied');
window.setTimeout(() => {
copyBtn.textContent = prev || '复制';
copyBtn.classList.remove('is-copied');
}, 1600);
} catch {
notify.error('复制失败');
} }
} catch {
notify.error('复制失败');
} }
}, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]); }, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]);
@@ -167,7 +129,7 @@ export default function PostContent({
className={className} className={className}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
dangerouslySetInnerHTML={{ __html: prepared.html }} dangerouslySetInnerHTML={{ __html: preparedHtml }}
/> />
<ImageLightbox <ImageLightbox
src={lightboxSrc} src={lightboxSrc}

View File

@@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { MessageCircle } from 'lucide-react'; import { MessageCircle } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge'; import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon'; import FeaturedIcon from '@/components/FeaturedIcon';
@@ -39,9 +39,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName); const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
const commentCount = post.comment_count ?? 0; const commentCount = post.comment_count ?? 0;
const href = postPath(post.id); const href = postPath(post.id);
const firstImage = firstImageFromHTML(post.content || ''); // 列表接口已预算摘要/首图且不下发正文;content 仅在详情等非列表场景有值时兜底
const firstImage = post.first_image || firstImageFromHTML(post.content || '');
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null; const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : ''; const excerpt = showExcerpt ? (post.excerpt || excerptFromHTML(post.content || '', 60)) : '';
const showBoardBadge = !!post.board && boardId !== post.board.id; const showBoardBadge = !!post.board && boardId !== post.board.id;
const openPost = () => onSelect(post.id); const openPost = () => onSelect(post.id);
@@ -61,18 +62,26 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
openPost(); openPost();
}; };
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = ( const titleRow = (
<div className="post-title-row"> <div className="post-title-row">
{post.pinned && ( {post.pinned > 0 && (
<span className="post-pin-badge" title="全局置顶">全局置顶</span> <span className="post-pin-badge" title="全局置顶">全局置顶</span>
)} )}
{post.board_pinned && ( {(post.board_pinned ?? 0) > 0 && (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span> <span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
)} )}
{post.featured && ( {post.featured && (
<span className="post-feature-badge" title="精华"> <span className="post-feature-badge" title="推荐">
<FeaturedIcon size={12} /> <FeaturedIcon size={12} />
精华 推荐
</span> </span>
)} )}
{post.status === 'pending' && ( {post.status === 'pending' && (
@@ -81,31 +90,36 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
{post.status === 'rejected' && ( {post.status === 'rejected' && (
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span> <span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
)} )}
{post.post_type === 'question' && (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">悬赏 {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
<a href={href} className="post-title" onClick={onTitleClick}> <a href={href} className="post-title" onClick={onTitleClick}>
{post.title} {post.title}
</a> </a>
{/* 类型徽章放标题后:flex 自动扣宽,长标题省略号紧挨徽章左侧 */}
{hasTypeBadge && (
<span className="post-title-type-badges">
{post.post_type === 'question' && (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">悬赏 {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
</span>
)}
</div> </div>
); );

View File

@@ -1,79 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton';
import type { ForumLimitsPublic } from '../api/types';
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
/** 虚拟列表行高预估值 */
export function feedListRowEstimate(style: FeedListStyle): number {
switch (style) {
case 'excerpt': return 68;
case 'thumbnail': return 72;
default: return 64;
}
}
interface Props {
count?: number;
listStyle?: FeedListStyle;
}
/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */
export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) {
const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail';
const showThumb = listStyle === 'thumbnail';
const titleOnly = listStyle === 'title';
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => {
const hasThumb = showThumb && i % 3 === 0;
return (
<div
key={i}
className={`post-row post-row--v2 post-row--skeleton${titleOnly ? ' post-row--title-only' : ''}${hasThumb ? ' post-row--has-thumb' : ''}`}
>
<Skeleton className="skeleton--avatar skeleton--avatar-v2" />
{hasThumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
</div>
<div className="post-aside">
<Skeleton className="skeleton--thumb skeleton--thumb-tall" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
</div>
<div className="post-meta">
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import {
Ban,
CircleCheck,
CircleHelp,
EllipsisVertical,
History,
Lock,
LockOpen,
MessageSquareOff,
Pencil,
Pin,
Sparkles,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { PostItem } from '../api/types';
export interface PostManageMenuProps {
post: PostItem;
isAdmin: boolean;
isOwnerOrAdmin: boolean;
canEdit: boolean;
isEdited: boolean;
isMobile?: boolean;
editRemaining?: string;
editBlockReason?: string;
deleting?: boolean;
onEdit: () => void;
onShowRevisions: () => void;
onToggleResolved: () => void;
onApprove: () => void;
onReject: () => void;
onFeature: () => void;
onPin: () => void;
onBoardPin: () => void;
onLock: () => void;
onCommentsLock: () => void;
onDelete: () => void;
}
/** 帖子右上角管理菜单:编辑、审核、置顶、锁定、删除等 */
export default function PostManageMenu({
post,
isAdmin,
isOwnerOrAdmin,
canEdit,
isEdited,
isMobile,
editRemaining,
editBlockReason,
deleting,
onEdit,
onShowRevisions,
onToggleResolved,
onApprove,
onReject,
onFeature,
onPin,
onBoardPin,
onLock,
onCommentsLock,
onDelete,
}: PostManageMenuProps) {
const showContent =
canEdit
|| (isOwnerOrAdmin && isEdited)
|| (isOwnerOrAdmin && post.post_type === 'question');
const hint = editRemaining || (!canEdit && isOwnerOrAdmin ? editBlockReason : '') || '';
if (!showContent && !isAdmin && !hint) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="post-manage-menu-trigger"
aria-label="管理帖子"
>
{!isMobile && <span>管理</span>}
<EllipsisVertical size={16} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="post-manage-menu">
{hint && (
<>
<DropdownMenuLabel className="post-manage-menu-hint">
{hint}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{showContent && (
<>
<DropdownMenuLabel>内容</DropdownMenuLabel>
{canEdit && (
<DropdownMenuItem onSelect={onEdit}>
<Pencil size={14} aria-hidden />
编辑
</DropdownMenuItem>
)}
{isOwnerOrAdmin && isEdited && (
<DropdownMenuItem onSelect={onShowRevisions}>
<History size={14} aria-hidden />
编辑历史
</DropdownMenuItem>
)}
{isOwnerOrAdmin && post.post_type === 'question' && (
<DropdownMenuItem onSelect={onToggleResolved}>
{post.question_resolved
? <CircleHelp size={14} aria-hidden />
: <CircleCheck size={14} aria-hidden />}
{post.question_resolved ? '标为未解决' : '标为已解决'}
</DropdownMenuItem>
)}
{isAdmin && <DropdownMenuSeparator />}
</>
)}
{isAdmin && (
<>
<DropdownMenuLabel>审核</DropdownMenuLabel>
{(post.status === 'pending' || post.status === 'rejected') && (
<DropdownMenuItem onSelect={onApprove}>
<CircleCheck size={14} aria-hidden />
通过审核
</DropdownMenuItem>
)}
{post.status !== 'rejected' && (
<DropdownMenuItem onSelect={onReject}>
<Ban size={14} aria-hidden />
拒绝并通知
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel>展示</DropdownMenuLabel>
<DropdownMenuItem onSelect={onFeature}>
<Sparkles size={14} aria-hidden />
{post.featured ? '取消推荐' : '设为推荐'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPin}>
<Pin size={14} aria-hidden />
{post.pinned > 0 ? '取消全局置顶' : '全局置顶'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onBoardPin}>
<Pin size={14} aria-hidden />
{(post.board_pinned ?? 0) > 0 ? '取消板块置顶' : '板块置顶'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>讨论</DropdownMenuLabel>
<DropdownMenuItem onSelect={onLock}>
<Lock size={14} aria-hidden />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCommentsLock}>
{post.comments_locked
? <LockOpen size={14} aria-hidden />
: <MessageSquareOff size={14} aria-hidden />}
{post.comments_locked ? '开放讨论' : '锁定讨论'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>危险</DropdownMenuLabel>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={deleting}
onSelect={onDelete}
>
<Trash2 size={14} aria-hidden />
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Loader2, ArrowDown } from 'lucide-react'; import { Loader2, ArrowDown } from 'lucide-react';
import { softRefreshCurrentPage } from '../utils/softRefresh';
/** 触发刷新的下拉距离(px) */ /** 触发刷新的下拉距离(px) */
const REFRESH_THRESHOLD = 68; const REFRESH_THRESHOLD = 68;
@@ -20,6 +21,12 @@ function pickScrollEl(): HTMLElement | null {
return null; return null;
} }
// Feed 文档滚动模式(.app-shell--doc-scroll):滚动根是 document(html)。
// 触摸事件会冒泡到 html,scrollTop 即窗口滚动量。
if (document.querySelector('.app-shell--doc-scroll')) {
return document.scrollingElement as HTMLElement | null;
}
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走) // 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll'); const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
if (mobileFeed) return mobileFeed; if (mobileFeed) return mobileFeed;
@@ -30,6 +37,9 @@ function pickScrollEl(): HTMLElement | null {
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)'); const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page; if (page) return page;
const showcase = document.querySelector<HTMLElement>('.showcase-panel');
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
const admin = document.querySelector<HTMLElement>('.admin-main'); const admin = document.querySelector<HTMLElement>('.admin-main');
if (admin) return admin; if (admin) return admin;
@@ -76,7 +86,7 @@ function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement):
} }
/** /**
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。 * 手机端下拉刷新:在内部滚动容器顶部下拉后强制刷新当前页。
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。) * (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
* *
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。 * 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
@@ -167,7 +177,22 @@ export default function PullToRefresh() {
setSettling(true); setSettling(true);
setPull(REFRESH_THRESHOLD * 0.7); setPull(REFRESH_THRESHOLD * 0.7);
window.setTimeout(() => { window.setTimeout(() => {
window.location.reload(); // 后台 / 登录页:整页 reload;前台:静默软刷新(无进度条,齐套后一次覆盖)
const isAdminOrAuth = !!(
document.querySelector('.admin-main')
|| document.querySelector('.auth-page')
);
if (isAdminOrAuth) {
window.location.reload();
return;
}
void softRefreshCurrentPage()
.catch(() => undefined)
.finally(() => {
setRefreshing(false);
setSettling(true);
setPull(0);
});
}, 180); }, 180);
return; return;
} }

View File

@@ -1,9 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react'; import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom'; import { useLocation, useSearchParams, useNavigate } from 'react-router';
import { Skeleton } from '@/components/ui/skeleton'; import type { AsideWidget, RecentComment, RecentUser, TagCount, User, UserActivityStats, UserPublic, ForumStats, FriendLink } from '../api/types';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime, formatTime } from '../utils/content'; import { formatShortDateTime, formatTime } from '../utils/content';
@@ -13,6 +11,7 @@ import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline'; import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard'; import PostAuthorCard from './PostAuthorCard';
import AsideCheckInStrip from './AsideCheckInStrip'; import AsideCheckInStrip from './AsideCheckInStrip';
import ShowcaseAsideWidget from './ShowcaseAsideWidget';
export type PostDetailAside = { export type PostDetailAside = {
author?: User | null; author?: User | null;
@@ -21,6 +20,9 @@ export type PostDetailAside = {
headings: PostHeading[]; headings: PostHeading[];
scrollRoot?: HTMLElement | null; scrollRoot?: HTMLElement | null;
outlineTitle?: string; outlineTitle?: string;
/** SSR/预热快照携带的作者主页资料与统计(首帧直接渲染,缺省卡片自行拉取) */
authorProfile?: UserPublic | null;
authorStats?: UserActivityStats | null;
}; };
interface Props { interface Props {
@@ -30,7 +32,7 @@ interface Props {
tagsLoading?: boolean; tagsLoading?: boolean;
stats?: ForumStats | null; stats?: ForumStats | null;
onPostClick: (id: number, opts?: { floor?: number }) => void; onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */ /** 首次拉取中:不渲染该块内容(由冷启动门闩保证首屏已齐或空白) */
loading?: boolean; loading?: boolean;
/** 右侧栏可选组件顺序与开关 */ /** 右侧栏可选组件顺序与开关 */
asideWidgets: AsideWidget[]; asideWidgets: AsideWidget[];
@@ -38,35 +40,6 @@ interface Props {
postDetail?: PostDetailAside | null; postDetail?: PostDetailAside | null;
} }
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<div className="widget-item-comment-main">
<Skeleton className="skeleton--widget-title" style={{ width: `${72 + (i % 3) * 8}%` }} />
<Skeleton className="skeleton--widget-meta" />
</div>
</div>
))}
</div>
);
}
function UserSkeleton() {
return (
<div className="widget-recent-users-grid" aria-busy="true" aria-label="用户加载中">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="widget-recent-user-cell widget-recent-user-cell--skeleton">
<Skeleton className="skeleton--recent-user-avatar" />
<Skeleton className="skeleton--recent-user-name" style={{ width: `${48 + (i % 3) * 10}%` }} />
</div>
))}
</div>
);
}
export default function RightPanel({ export default function RightPanel({
recentComments, recentComments,
recentUsers, recentUsers,
@@ -108,12 +81,10 @@ export default function RightPanel({
[asideWidgets], [asideWidgets],
); );
const handleApplyClick = () => {
nav('/links?apply=1');
};
const renderWidget = (widget: AsideWidget) => { const renderWidget = (widget: AsideWidget) => {
switch (widget.id) { switch (widget.id) {
case 'showcase':
return <ShowcaseAsideWidget key="showcase" />;
case 'friend_links': case 'friend_links':
return ( return (
<div key="friend_links" className="widget-card widget-card--friend-links"> <div key="friend_links" className="widget-card widget-card--friend-links">
@@ -124,15 +95,9 @@ export default function RightPanel({
友情链接 友情链接
</button> </button>
</span> </span>
<Button <a href="/links?apply=1" className="widget-friend-links-apply">
type="button"
variant="ghost"
size="sm"
className="widget-friend-links-apply"
onClick={handleApplyClick}
>
申请 申请
</Button> </a>
</div> </div>
<div className="widget-card-body widget-card-body--friend-links"> <div className="widget-card-body widget-card-body--friend-links">
{friendLinks.length === 0 ? ( {friendLinks.length === 0 ? (
@@ -178,9 +143,7 @@ export default function RightPanel({
最新评论 最新评论
</div> </div>
<div className="widget-card-body"> <div className="widget-card-body">
{loading && commentList.length === 0 ? ( {loading && commentList.length === 0 ? null : commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty">暂无评论</div> <div className="widget-empty">暂无评论</div>
) : commentList.map(item => ( ) : commentList.map(item => (
<div <div
@@ -238,9 +201,7 @@ export default function RightPanel({
最新注册 最新注册
</div> </div>
<div className="widget-card-body widget-card-body--users"> <div className="widget-card-body widget-card-body--users">
{loading && userList.length === 0 ? ( {loading && userList.length === 0 ? null : userList.length === 0 ? (
<UserSkeleton />
) : userList.length === 0 ? (
<div className="widget-empty">暂无用户</div> <div className="widget-empty">暂无用户</div>
) : ( ) : (
<div className="widget-recent-users-grid"> <div className="widget-recent-users-grid">
@@ -274,11 +235,15 @@ export default function RightPanel({
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}> <div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && ( {isPostDetail && (
<> <>
<PostAuthorCard {postDetail.author?.id ? (
author={postDetail.author} <PostAuthorCard
publishedAt={postDetail.publishedAt} author={postDetail.author}
viewCount={postDetail.viewCount} publishedAt={postDetail.publishedAt}
/> viewCount={postDetail.viewCount}
initialProfile={postDetail.authorProfile ?? null}
initialStats={postDetail.authorStats ?? null}
/>
) : null}
<div className="widget-card widget-card--outline"> <div className="widget-card widget-card--outline">
<div className="widget-card-head"> <div className="widget-card-head">
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden /> <ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
const SHOWCASE_KEY = 'showcase';
/** 右侧栏:开源展柜精简列表 */
export default function ShowcaseAsideWidget() {
const nav = useNavigate();
const cached = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
const [items, setItems] = useState<CommunityShowcaseItem[]>(() => cached ?? []);
const [loading, setLoading] = useState(() => cached === undefined);
useEffect(() => {
let cancelled = false;
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
// 非空快照直接用;空数组可能是启动竞态假空,仍再拉
if (hit !== undefined && hit.length > 0) {
setItems(hit);
setLoading(false);
return;
}
if (hit !== undefined) setItems(hit);
setLoading(hit === undefined);
api.communityShowcase()
.then((r) => {
if (cancelled) return;
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
// 失败不写空快照,避免假空粘死
if (!cancelled && hit === undefined) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
const fetchShowcase = (opts: { showLoading: boolean }) => {
if (opts.showLoading) setLoading(true);
api.communityShowcase()
.then((r) => {
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
// 软刷新 / 强刷失败:保持旧 UI,不写空快照
})
.finally(() => setLoading(false));
};
const applyHitOrReload = (showLoading: boolean) => {
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined && hit.length > 0) {
setItems(hit);
setLoading(false);
return;
}
fetchShowcase({ showLoading });
};
const onForce = () => applyHitOrReload(true);
const onCommit = () => applyHitOrReload(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, []);
return (
<div className="widget-card widget-card--showcase">
<div className="widget-card-head widget-card-head--split">
<span className="widget-card-head-main">
<Globe2 className="widget-card-icon widget-card-icon--showcase" aria-hidden />
<button type="button" className="widget-friend-links-title" onClick={() => nav('/showcase')}>
开源展柜
</button>
</span>
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
全部
</button>
</div>
<div className="widget-card-body widget-card-body--friend-links">
{loading ? (
<div className="widget-empty">加载中…</div>
) : items.length === 0 ? (
<div className="widget-empty">
暂无精选实例
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
查看展柜
</button>
</div>
) : (
<>
<ul className="widget-friend-links-list">
{items.slice(0, 8).map((item) => (
<li key={item.site_url}>
<a href={item.site_url} target="_blank" rel="noopener noreferrer" title={item.site_name || item.site_url}>
{item.site_name || '未命名站点'}
</a>
</li>
))}
</ul>
{items.length > 8 && (
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
查看全部 {items.length} 个
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import { import {
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useLocation, useSearchParams, Link } from 'react-router';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar'; import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache'; import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import BoardIconDisplay from './BoardIconDisplay'; import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme'; import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline'; import ArticleOutline from './ArticleOutline';
@@ -30,6 +30,7 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
if (pathname.startsWith('/favorites')) return 'favorites'; if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects'; if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/links')) return 'links'; if (pathname.startsWith('/links')) return 'links';
if (pathname.startsWith('/showcase')) return 'showcase';
if (pathname.startsWith('/page/')) return 'pages'; if (pathname.startsWith('/page/')) return 'pages';
if (pathname.startsWith('/admin')) return 'admin'; if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard); return activeBoard === 0 ? 'all' : String(activeBoard);
@@ -61,13 +62,14 @@ export default function Sidebar({
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages(); const { navPages } = useSitePages();
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs);
const showFriendLinksNav = limits.nav_show_friend_links !== false; const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showSiteSection = navPages.length > 0 || showFriendLinksNav; const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
const keyword = params.get('keyword') || ''; const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword); const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
@@ -135,22 +137,12 @@ export default function Sidebar({
<div className="sidebar-section">浏览</div> <div className="sidebar-section">浏览</div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)} {feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))} {user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => void transitionTo(nav, '/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))} {navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => void transitionTo(nav, '/projects'))}
</nav> </nav>
{(boardsLoading && boards.length === 0) ? ( {(boardsLoading && boards.length === 0) ? (
<> <div className="sidebar-section sidebar-section--boards">板块</div>
<div className="sidebar-section sidebar-section--boards">板块</div>
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
<Skeleton className="skeleton--sidebar-icon" />
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
</div>
))}
</nav>
</>
) : boards.length > 0 ? ( ) : boards.length > 0 ? (
<> <>
<div className="sidebar-section sidebar-section--boards">板块</div> <div className="sidebar-section sidebar-section--boards">板块</div>
@@ -196,9 +188,10 @@ export default function Sidebar({
<> <>
<div className="sidebar-section sidebar-section--spaced">站点</div> <div className="sidebar-section sidebar-section--spaced">站点</div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))} {showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => void transitionTo(nav, '/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => void transitionTo(nav, '/showcase'))}
{navPages.map(p => ( {navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits))) navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => void transitionTo(nav, pagePath(p.slug, limits)))
))} ))}
</nav> </nav>
</> </>

View File

@@ -1,6 +1,6 @@
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMediaQuery } from '../hooks/useTheme'; import { useMediaQuery } from '../hooks/useTheme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router';
import { useSitePages } from '../hooks/useSitePages'; import { useSitePages } from '../hooks/useSitePages';
import { pagePath } from '../utils/permalink'; import { pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>; return <span className="site-footer__sep" aria-hidden>·</span>;
} }
/** 站点页脚:版权、Sitemap、备案号 */ /** 站点页脚:版权、友链/展柜入口、单页、备案号(结构与 Go writeSSRFooter 对齐) */
export default function SiteFooter() { export default function SiteFooter() {
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
const { footerPages } = useSitePages(); const { footerPages } = useSitePages();
@@ -17,6 +17,44 @@ export default function SiteFooter() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const icp = branding.icp_beian?.trim() || ''; const icp = branding.icp_beian?.trim() || '';
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/'; const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase;
const navItems: React.ReactNode[] = [];
if (showFriendLinks) {
navItems.push(
<span key="links" className="site-footer__friend">
<Link to="/links">友情链接</Link>
</span>,
);
}
if (showShowcase) {
navItems.push(
<span key="showcase" className="site-footer__friend">
<Link to="/showcase">开源展柜</Link>
</span>,
);
}
for (const p of footerPages) {
navItems.push(
<span key={p.slug} className="site-footer__friend">
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>,
);
}
if (icp) {
navItems.push(
<a
key="icp"
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>,
);
}
return ( return (
<footer className="site-footer"> <footer className="site-footer">
@@ -34,29 +72,8 @@ export default function SiteFooter() {
</div> </div>
<nav className="site-footer__nav" aria-label="站点链接"> <nav className="site-footer__nav" aria-label="站点链接">
{limits.footer_show_friend_links !== false && ( {navItems.flatMap((node, i) =>
<span className="site-footer__friend"> i === 0 ? [node] : [<FooterSep key={`sep-${i}`} />, node],
<Link to="/links">友情链接</Link>
</span>
)}
{footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend">
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />}
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
)} )}
</nav> </nav>
</div> </div>

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import type { TagCount } from '../api/types'; import type { TagCount } from '../api/types';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
}, [tags]); }, [tags]);
if (loading && tags.length === 0) { if (loading && tags.length === 0) {
return ( return null;
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
{Array.from({ length: 10 }, (_, i) => (
<Skeleton
key={i}
className="skeleton--tag-cloud"
style={{
width: `${42 + (i % 5) * 16}px`,
height: `${20 + (i % 3) * 4}px`,
}}
/>
))}
</div>
);
} }
if (items.length === 0) { if (items.length === 0) {

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { subscribeSpaTransition } from '../utils/spaTransition';
/**
* 视口顶部 2px 细进度条(类 Next.js / YouTube)。
* 由 spaTransition start/done 驱动,假进度爬升至约 80%,结束冲到 100%。
*/
export default function TopProgressBar() {
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);
const [fading, setFading] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef(false);
useEffect(() => {
const clearTick = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const clearFade = () => {
if (fadeRef.current) {
clearTimeout(fadeRef.current);
fadeRef.current = null;
}
};
return subscribeSpaTransition((active) => {
if (active) {
activeRef.current = true;
clearFade();
setFading(false);
setVisible(true);
setWidth(12);
clearTick();
timerRef.current = setInterval(() => {
setWidth((w) => {
if (w >= 80) return w;
// 越接近 80 越慢
const step = Math.max(0.4, (80 - w) * 0.045);
return Math.min(80, w + step);
});
}, 120);
return;
}
if (!activeRef.current) return;
activeRef.current = false;
clearTick();
setWidth(100);
setFading(true);
clearFade();
fadeRef.current = setTimeout(() => {
setVisible(false);
setFading(false);
setWidth(0);
}, 220);
});
}, []);
if (!visible && width <= 0) return null;
return (
<div
className={[
'top-progress',
fading ? 'top-progress--done' : '',
].filter(Boolean).join(' ')}
style={{ width: `${width}%` }}
aria-hidden
/>
);
}

View File

@@ -1,5 +1,5 @@
import type { MouseEvent, ReactNode } from 'react'; import type { MouseEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { UserBadge } from '../api/types'; import type { UserBadge } from '../api/types';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';

View File

@@ -1,315 +0,0 @@
import { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
/** 当前页之后是否还有更多 */
hasMore: boolean;
/** 是否显示底部分页控件 */
showPagination: boolean;
page: number;
totalPages: number;
postTotal: number;
onPageChange: (page: number) => void;
onSelect: (id: number) => void;
restoreScrollTop?: number | null;
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
/** 搜索关键词(用于空态文案) */
keyword?: string;
/** 是否为帖子搜索(区别于标签筛选) */
isSearchMode?: boolean;
searchKeyword?: string;
searchAuthor?: string;
searchTitleOnly?: boolean;
searchScopeBoardId?: number;
onClearSearch?: () => void;
/** 当前板块 id,0 表示全部 */
boardId?: number;
/** 当前板块名 */
boardName?: string;
/** 站点是否尚无任何板块(全新安装) */
noBoards?: boolean;
}
/** 手机 Feed 整栏滚动时的容器 */
function getMobileFeedScrollEl(): HTMLElement | null {
return document.querySelector('.main-content--feed-mobile-scroll');
}
export default function VirtualPostList({
posts,
sort = 'latest',
loading,
hasMore,
showPagination,
page,
totalPages,
postTotal,
onPageChange,
onSelect,
restoreScrollTop,
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
keyword = '',
isSearchMode = false,
searchKeyword = '',
searchAuthor = '',
searchTitleOnly = false,
searchScopeBoardId = 0,
onClearSearch,
boardId = 0,
boardName = '',
noBoards = false,
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
const { limits } = useForumLimits();
const isMobile = useMediaQuery('(max-width: 768px)');
const feedStyle = limits.feed_list_style ?? 'title';
const rowEstimate = feedListRowEstimate(feedStyle);
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange);
const onScrollRestoredRef = useRef(onScrollRestored);
onScrollTopChangeRef.current = onScrollTopChange;
onScrollRestoredRef.current = onScrollRestored;
const [scrollMargin, setScrollMargin] = useState(0);
const getScrollElement = useCallback(() => {
if (isMobile) {
return getMobileFeedScrollEl() ?? parentRef.current;
}
return parentRef.current;
}, [isMobile]);
/** 列表相对整栏滚动容器顶部的偏移,供虚拟列表对齐 */
useLayoutEffect(() => {
if (!isMobile) {
setScrollMargin(0);
return;
}
const main = getMobileFeedScrollEl();
const list = parentRef.current;
if (!main || !list) return;
const update = () => {
// 相对 main 内容顶(含当前 scrollTop),避免 offsetParent 链不准
const next = Math.max(
0,
Math.round(list.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop),
);
setScrollMargin(prev => (prev === next ? prev : next));
};
update();
const ro = new ResizeObserver(update);
ro.observe(main);
const boardBar = main.querySelector('.mobile-board-bar');
const feedTop = main.querySelector('.feed-top');
if (boardBar) ro.observe(boardBar);
if (feedTop) ro.observe(feedTop);
return () => ro.disconnect();
}, [isMobile, posts.length, loading, keyword, boardId]);
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement,
estimateSize: () => rowEstimate,
overscan: 8,
scrollMargin: isMobile ? scrollMargin : 0,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
: undefined,
});
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim());
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
const el = getScrollElement();
if (el) {
el.scrollTop = 0;
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer, getScrollElement]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
const el = getScrollElement();
if (el) {
el.scrollTop = restoreScrollTop;
} else {
virtualizer.scrollToOffset(restoreScrollTop);
}
restoredRef.current = true;
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;
const onScroll = () => {
onScrollTopChangeRef.current?.(el.scrollTop);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [getScrollElement, isMobile]);
const searchSummaryParts: string[] = [];
if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}」`);
if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`);
if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题');
if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`);
const emptyActions = (
<div className="empty-feed-actions">
{noBoards ? (
isAdmin ? (
<Button type="button" size="sm" onClick={() => nav('/admin/boards')}>
去创建板块
</Button>
) : (
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
刷新看看
</Button>
)
) : isSearchEmpty ? (
<>
<Button type="button" size="sm" variant="outline" onClick={dispatchOpenPostSearch}>
修改搜索
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => (onClearSearch ? onClearSearch() : nav('/'))}>
清除筛选
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
返回全部帖子
</Button>
</>
) : (
<>
{boardId > 0 && (
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
看看其他板块
</Button>
)}
{user ? (
<Button type="button" size="sm" onClick={() => nav(composeTarget)}>
{boardName ? `成为「${boardName}」第一帖` : '发第一帖'}
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath(composeTarget))}>
登录后发帖
</Button>
)}
</>
)}
</div>
);
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton listStyle={feedStyle} />
) : isEmpty ? (
<div className="empty-feed" role="status">
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
<p>
{noBoards
? '暂无板块'
: isSearchEmpty
? '没有匹配的帖子'
: '暂无帖子'}
</p>
<p className="empty-feed-hint">
{noBoards
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
: isSearchEmpty
? (searchSummaryParts.length > 0
? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
: '试试更短的关键词,或浏览标签云 / 板块')
: boardName
? `「${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}
</p>
{emptyActions}
</div>
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
if (!post) return null;
// scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位
const offsetY = isMobile ? vi.start - scrollMargin : vi.start;
return (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${offsetY}px)`,
}}
>
<PostListItem post={post} sort={sort} boardId={boardId} onSelect={onSelect} />
</div>
);
})}
</div>
{showPagination && (
<div className="feed-list-footer feed-list-footer--pagination">
<FeedPagination
page={page}
totalPages={totalPages}
postTotal={postTotal}
loading={loading}
onPageChange={onPageChange}
/>
</div>
)}
{showEnd && (
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
)}
</>
)}
<InFlowSiteFooter />
</div>
);
}

View File

@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
label: '友情链接', label: '友情链接',
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请', hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
}, },
showcase: {
label: '开源展柜',
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
},
}; };
type Props = { type Props = {

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { Heart } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import type { CommunityConfig } from '../../api/types';
const EMPTY_COMMUNITY: CommunityConfig = {
report_enabled: false,
hub_enabled: false,
hub_url: 'https://bbs.iioio.com',
site_url: '',
instance_id: '',
};
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存;枢纽站不显示) */
export default function CommunitySupportStrip() {
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
api.adminSettings()
.then((s) => {
if (!cancelled) {
setCommunity({ ...EMPTY_COMMUNITY, ...(s.community ?? {}) });
setReady(true);
}
})
.catch(() => {
if (!cancelled) setReady(true);
});
return () => { cancelled = true; };
}, []);
const handleToggle = async () => {
if (saving || !ready) return;
const next = !community.report_enabled;
const prev = community;
setCommunity((c) => ({ ...c, report_enabled: next }));
setSaving(true);
try {
const r = await api.adminUpdateCommunitySettings({
...EMPTY_COMMUNITY,
report_enabled: next,
});
setCommunity({ ...EMPTY_COMMUNITY, ...r.community });
if (r.heartbeat_error) {
notify.warning(`${r.message}:${r.heartbeat_error}`);
} else {
notify.success(next ? '已开启社区上报' : '已关闭社区上报');
}
} catch (e: unknown) {
setCommunity(prev);
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
// 官网 / 枢纽站本身就是收报方,无需「支持开源」上报条
if (!ready || community.hub_enabled) {
return null;
}
return (
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
<div className="admin-community-support-strip-main">
<Heart size={16} className="admin-community-support-strip-icon" aria-hidden />
<div className="admin-community-support-strip-copy">
<strong>支持姜十三开源</strong>
<span>
匿名向 bbs.iioio.com 上报站点地址、版本与规模;开启后有机会获官方演示站展示与推荐,可随时关闭
</span>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={community.report_enabled}
aria-busy={saving}
disabled={saving || !ready}
className={`admin-settings-switch${community.report_enabled ? ' is-on' : ''}`}
onClick={() => void handleToggle()}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { Pencil } from 'lucide-react';
import { Input } from '@/components/ui/input';
import AdminSortableList, { SortableDragHandle } from './AdminSortableList';
import type { FeedSortId, FeedSortTab } from '../../api/types';
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
reply: { hint: '按最近活动(发帖或评论)', placeholder: '最新' },
latest: { hint: '按发帖时间', placeholder: '新帖子' },
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
};
type Props = {
tabs: FeedSortTab[];
onChange: (next: FeedSortTab[]) => void;
};
export default function FeedSortTabList({ tabs, onChange }: Props) {
const items = normalizeFeedSortTabs(tabs);
const handleToggle = (id: FeedSortId, enabled: boolean) => {
const next = items.map(t => (t.id === id ? { ...t, enabled } : t));
// 不允许全部关闭:关掉最后一项时忽略
if (!enabled && !next.some(t => t.enabled)) return;
onChange(normalizeFeedSortTabs(next));
};
const handleLabel = (id: FeedSortId, label: string) => {
onChange(normalizeFeedSortTabs(items.map(t => (t.id === id ? { ...t, label } : t))));
};
return (
<AdminSortableList
items={items}
getId={tab => tab.id}
onReorder={next => onChange(normalizeFeedSortTabs(next))}
showMoveButtons={false}
className="admin-sortable-list admin-sortable-list--boxed"
ariaLabel="首页排序标签"
renderItem={(tab, _index, controls) => {
const meta = TAB_META[tab.id];
return (
<div
ref={controls.setNodeRef}
style={controls.style}
className={`admin-sortable-row admin-sortable-row--widget${controls.isDragging ? ' is-dragging' : ''}`}
>
<SortableDragHandle
label={`拖拽调整「${tab.label || meta.placeholder}」顺序`}
{...controls.dragHandleProps}
/>
<div className="admin-sortable-row__main">
<label className="admin-sortable-row__field" htmlFor={`feed-sort-label-${tab.id}`}>
<span className="admin-sortable-row__field-name">显示名称</span>
<span className="admin-sortable-row__name-wrap">
<Input
id={`feed-sort-label-${tab.id}`}
type="text"
className="admin-sortable-row__name-input"
value={tab.label}
maxLength={16}
placeholder={meta.placeholder}
onChange={e => handleLabel(tab.id, e.target.value)}
aria-describedby={`feed-sort-hint-${tab.id}`}
/>
<Pencil className="admin-sortable-row__name-icon" size={14} aria-hidden />
</span>
</label>
<span className="admin-sortable-row__hint" id={`feed-sort-hint-${tab.id}`}>
{meta.hint}
</span>
</div>
<button
type="button"
id={`feed-sort-${tab.id}`}
role="switch"
aria-checked={tab.enabled}
aria-label={`启用「${tab.label || meta.placeholder}」`}
className={`admin-settings-switch${tab.enabled ? ' is-on' : ''}`}
onClick={() => handleToggle(tab.id, !tab.enabled)}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}}
/>
);
}

View File

@@ -0,0 +1,91 @@
import { useMemo, useState } from 'react';
import china from '@svg-maps/china';
import { cn } from '@/lib/utils';
import { buildChinaRegionCountMap, heatFill } from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type RegionStat = {
country: string;
region?: string;
region_iso?: string;
count: number;
};
type Props = {
regions: RegionStat[];
className?: string;
/** 近 30 日是否已有国家级浏览量 */
hasCountryHits?: boolean;
/** 近 30 日是否已有省级浏览量 */
hasRegionHits?: boolean;
};
/** 中国省区地图:按 BIN 解析出的省/区填色 */
export default function MonitorChinaMap({
regions,
className,
hasCountryHits = false,
hasRegionHits = false,
}: Props) {
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
const viewBox = (china as { viewBox: string }).viewBox;
let emptyTitle = '暂无省级访问数据';
let emptyHint = '放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色';
if (hasCountryHits && !hasRegionHits) {
emptyTitle = '有国家浏览量,尚无省级数据';
emptyHint = '需前台路由产生带省字段的浏览量;仅请求日志不会点亮中国地图。请确认已放置 IP2Location DB3 BIN。';
} else if (!hasCountryHits) {
emptyTitle = '暂无浏览量地理数据';
emptyHint = '需前台路由产生浏览量后才会按省填色;仅请求日志不会点亮此处。';
}
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label="中国访客地图"
>
{locations.map((loc) => {
const count = counts[loc.id] || 0;
return (
<path
key={loc.id}
d={loc.path}
fill={heatFill(count, max, hasData)}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={0.6}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({ name: loc.name, count })}
onMouseLeave={() => setHover(null)}
>
<title>
{loc.name}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && (
<div className="admin-monitor-map-empty-overlay">
<p>{emptyTitle}</p>
<p>{emptyHint}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import world from '@svg-maps/world';
import { cn } from '@/lib/utils';
import {
buildCountMap,
countryLabelZh,
emptyFill,
heatFill,
type GeoCountMap,
} from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type Props = {
items: { country: string; count: number }[];
className?: string;
/** 裁切到东亚(中国模式) */
focusChina?: boolean;
};
const CHINA_VIEWBOX = '680 260 220 200';
export default function MonitorWorldMap({ items, className, focusChina }: Props) {
const [hover, setHover] = useState<{ code: string; name: string; count: number } | null>(null);
const counts = useMemo(() => buildCountMap(items), [items]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (world as { locations: Loc[]; viewBox: string }).locations;
const viewBox = focusChina ? CHINA_VIEWBOX : (world as { viewBox: string }).viewBox;
const filteredCounts: GeoCountMap = useMemo(() => {
if (!focusChina) return counts;
const allow = new Set(['CN', 'HK', 'MO', 'TW']);
const m: GeoCountMap = {};
for (const [k, v] of Object.entries(counts)) {
if (allow.has(k)) m[k] = v;
}
return m;
}, [counts, focusChina]);
const focusMax = useMemo(
() => Math.max(0, ...Object.values(filteredCounts)),
[filteredCounts],
);
const focusHas = focusMax > 0;
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label={focusChina ? '中国及周边访客地图' : '世界访客地图'}
>
{locations.map((loc) => {
const code = loc.id.toUpperCase();
if (focusChina) {
// 中国模式下非本区域保持极淡灰,本区域按数据着色
const inRegion = code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW'
|| code === 'JP' || code === 'KR' || code === 'MN' || code === 'KP'
|| code === 'RU' || code === 'IN' || code === 'VN' || code === 'LA'
|| code === 'MM' || code === 'BT' || code === 'NP' || code === 'KZ'
|| code === 'KG' || code === 'TJ' || code === 'UZ' || code === 'AF'
|| code === 'PK' || code === 'PH' || code === 'MY' || code === 'TH'
|| code === 'KH' || code === 'BD' || code === 'LK';
if (!inRegion) return null;
}
const count = filteredCounts[code] || (focusChina ? 0 : counts[code]) || 0;
const useMax = focusChina ? focusMax : max;
const useHas = focusChina ? focusHas : hasData;
const isCore = !focusChina || code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW';
const fill = isCore
? heatFill(count, useMax, useHas)
: emptyFill(useHas);
return (
<path
key={loc.id}
d={loc.path}
data-code={code}
fill={fill}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={focusChina ? 0.35 : 0.4}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({
code,
name: countryLabelZh(code, loc.name),
count: focusChina && !isCore ? 0 : count,
})}
onMouseLeave={() => setHover(null)}
>
<title>
{countryLabelZh(code, loc.name)}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && !focusChina && (
<div className="admin-monitor-map-empty-overlay">
<p>暂无国家数据</p>
<p>配置 GeoIP 或 CDN 国家头后可见</p>
</div>
)}
{focusChina && !focusHas && (
<div className="admin-monitor-map-empty-overlay">
<p>暂无中国地区访问数据</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
/** 监控地图共用:国家码 → 数量色阶(本站绿色) */
export type GeoCountMap = Record<string, number>;
const EMPTY = 'var(--j13-border, #e5e7eb)';
const BASE = 'hsl(var(--muted, 210 20% 94%))';
/** 由浅到深的绿色阶 */
const GREEN_STEPS = [
'color-mix(in srgb, var(--j13-green) 28%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 45%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 62%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 80%, hsl(var(--card)))',
'var(--j13-green)',
];
export function buildCountMap(items: { country: string; count: number }[]): GeoCountMap {
const m: GeoCountMap = {};
for (const it of items) {
const code = (it.country || '').toUpperCase();
if (!code) continue;
m[code] = (m[code] || 0) + it.count;
}
return m;
}
/** MaxMind region_iso / 中英文省名 → @svg-maps/china 的 location id */
const CN_ISO_TO_SVG: Record<string, string> = {
AH: 'anhui', BJ: 'beijing', CQ: 'chongqing', FJ: 'fujian', GS: 'gansu',
GD: 'guangdong', GX: 'guangxi-zhuang', GZ: 'guizhou', HI: 'hainan', HE: 'hebei',
HL: 'heilongjiang', HA: 'henan', HB: 'hubei', HN: 'hunan', JS: 'jiangsu',
JX: 'jiangxi', JL: 'jilin', LN: 'liaoning', NM: 'nei-mongol', NX: 'ningxia-hui',
QH: 'quinghai', SN: 'shaanxi', SD: 'shandong', SH: 'shanghai', SX: 'shanxi',
SC: 'sichuan', TJ: 'tianjin', XJ: 'xinjiang-uygur', XZ: 'xizang', YN: 'yunnan',
ZJ: 'zhejiang', HK: 'hong-kong', MO: 'macau',
};
const CN_NAME_TO_SVG: Record<string, string> = {
安徽: 'anhui', anhui: 'anhui',
北京: 'beijing', beijing: 'beijing',
重庆: 'chongqing', chongqing: 'chongqing',
福建: 'fujian', fujian: 'fujian',
甘肃: 'gansu', gansu: 'gansu',
广东: 'guangdong', guangdong: 'guangdong',
广西: 'guangxi-zhuang', '广西壮族自治区': 'guangxi-zhuang', 'guangxi zhuang': 'guangxi-zhuang', guangxi: 'guangxi-zhuang',
贵州: 'guizhou', guizhou: 'guizhou',
海南: 'hainan', hainan: 'hainan',
河北: 'hebei', hebei: 'hebei',
黑龙江: 'heilongjiang', heilongjiang: 'heilongjiang',
河南: 'henan', henan: 'henan',
湖北: 'hubei', hubei: 'hubei',
湖南: 'hunan', hunan: 'hunan',
江苏: 'jiangsu', jiangsu: 'jiangsu',
江西: 'jiangxi', jiangxi: 'jiangxi',
吉林: 'jilin', jilin: 'jilin',
辽宁: 'liaoning', liaoning: 'liaoning',
内蒙古: 'nei-mongol', '内蒙古自治区': 'nei-mongol', 'nei mongol': 'nei-mongol', 'inner mongolia': 'nei-mongol',
宁夏: 'ningxia-hui', '宁夏回族自治区': 'ningxia-hui', 'ningxia hui': 'ningxia-hui', ningxia: 'ningxia-hui',
青海: 'quinghai', qinghai: 'quinghai', quinghai: 'quinghai',
陕西: 'shaanxi', shaanxi: 'shaanxi',
山东: 'shandong', shandong: 'shandong',
上海: 'shanghai', shanghai: 'shanghai',
山西: 'shanxi', shanxi: 'shanxi',
四川: 'sichuan', sichuan: 'sichuan',
天津: 'tianjin', tianjin: 'tianjin',
新疆: 'xinjiang-uygur', '新疆维吾尔自治区': 'xinjiang-uygur', 'xinjiang uygur': 'xinjiang-uygur', xinjiang: 'xinjiang-uygur',
西藏: 'xizang', '西藏自治区': 'xizang', xizang: 'xizang', tibet: 'xizang',
云南: 'yunnan', yunnan: 'yunnan',
浙江: 'zhejiang', zhejiang: 'zhejiang',
香港: 'hong-kong', 'hong kong': 'hong-kong',
澳门: 'macau', macau: 'macau', macao: 'macau',
};
export function chinaRegionToSvgId(regionISO?: string, regionName?: string): string | null {
const iso = (regionISO || '').trim().toUpperCase().replace(/^CN-/, '');
if (iso && CN_ISO_TO_SVG[iso]) return CN_ISO_TO_SVG[iso];
const name = (regionName || '').trim().toLowerCase();
if (!name) return null;
if (CN_NAME_TO_SVG[name]) return CN_NAME_TO_SVG[name];
// 尝试去掉「省/市/自治区」后缀后再匹配中文键
const zh = (regionName || '').trim()
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
.replace(/(省|市)$/, '');
if (CN_NAME_TO_SVG[zh]) return CN_NAME_TO_SVG[zh];
return null;
}
/** 将省级统计聚合为 svg-maps/china 的 id → count */
export function buildChinaRegionCountMap(
regions: { country: string; region?: string; region_iso?: string; count: number }[],
): GeoCountMap {
const m: GeoCountMap = {};
for (const it of regions) {
const c = (it.country || '').toUpperCase();
if (c && c !== 'CN' && c !== 'HK' && c !== 'MO' && c !== 'TW') continue;
const id = chinaRegionToSvgId(it.region_iso, it.region);
if (!id) continue;
m[id] = (m[id] || 0) + it.count;
}
return m;
}
export function heatFill(count: number, max: number, hasAnyData: boolean): string {
if (!hasAnyData) return BASE;
if (!count || max <= 0) return BASE;
const t = Math.log1p(count) / Math.log1p(max);
const idx = Math.min(GREEN_STEPS.length - 1, Math.floor(t * GREEN_STEPS.length));
return GREEN_STEPS[Math.max(0, idx)];
}
export function emptyFill(hasAnyData: boolean): string {
return hasAnyData ? BASE : EMPTY;
}
export const COUNTRY_NAMES_ZH: Record<string, string> = {
CN: '中国', US: '美国', JP: '日本', KR: '韩国', HK: '中国香港', TW: '中国台湾', MO: '中国澳门',
SG: '新加坡', DE: '德国', GB: '英国', FR: '法国', RU: '俄罗斯', AU: '澳大利亚',
CA: '加拿大', IN: '印度', BR: '巴西', NL: '荷兰', IT: '意大利', ES: '西班牙',
MY: '马来西亚', TH: '泰国', VN: '越南', ID: '印尼', PH: '菲律宾', MX: '墨西哥',
TR: '土耳其', SA: '沙特', AE: '阿联酋', ZA: '南非', NZ: '新西兰', SE: '瑞典',
NO: '挪威', FI: '芬兰', DK: '丹麦', PL: '波兰', CH: '瑞士', AT: '奥地利',
BE: '比利时', IE: '爱尔兰', PT: '葡萄牙', AR: '阿根廷', CL: '智利', CO: '哥伦比亚',
PK: '巴基斯坦', BD: '孟加拉', EG: '埃及', NG: '尼日利亚', KE: '肯尼亚',
UA: '乌克兰', CZ: '捷克', RO: '罗马尼亚', HU: '匈牙利', GR: '希腊', IL: '以色列',
};
export function countryLabelZh(code: string, fallbackName?: string) {
const c = code.toUpperCase();
return COUNTRY_NAMES_ZH[c] || fallbackName || c;
}

View File

@@ -1,5 +1,6 @@
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import { mergeAttributes } from '@tiptap/core'; import { mergeAttributes } from '@tiptap/core';
import { isStickerSrc } from './ArticleStickerExtension';
/** 单图展示形态(对齐 Notion / Medium 常见选项) */ /** 单图展示形态(对齐 Notion / Medium 常见选项) */
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right'; export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
@@ -19,6 +20,19 @@ declare module '@tiptap/core' {
export const ArticleImage = Image.extend({ export const ArticleImage = Image.extend({
name: 'image', name: 'image',
parseHTML() {
return [
{
tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])',
getAttrs: (node) => {
if (typeof node === 'string') return false;
if (isStickerSrc(node.getAttribute('src'))) return false;
return null;
},
},
];
},
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),

View File

@@ -0,0 +1,351 @@
import { useCallback, useEffect, useRef, useState, type DragEvent } 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';
import { notify } from '@/lib/notify';
import { api } from '@/api/client';
import type { MediaItem } from '@/api/types';
import { toPostImageThumbSrc } from '@/utils/postContent';
import { Upload, Link2, Images, Loader2 } from 'lucide-react';
export type ImagePickerTarget = 'rich' | 'markdown';
type PickerTab = 'upload' | 'link' | 'gallery';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 上传或选中后插入一张或多张图片 URL */
onInsert: (urls: string[]) => void;
}
/** 校验可插入的图片地址:外链或站内绝对路径 */
export function isValidImageSrc(url: string): boolean {
const u = url.trim();
if (!u) return false;
if (u.startsWith('/') && !u.startsWith('//')) return true;
try {
const parsed = new URL(u);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
const PAGE_SIZE = 24;
/** 文章编辑器:统一图片插入(上传 / 链接 / 我的图片) */
export function ArticleImagePickerDialog({
open,
onOpenChange,
onInsert,
}: Props) {
const [tab, setTab] = useState<PickerTab>('upload');
const [url, setUrl] = useState('');
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [gallery, setGallery] = useState<MediaItem[]>([]);
const [galleryPage, setGalleryPage] = useState(1);
const [galleryTotalPages, setGalleryTotalPages] = useState(1);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryLoaded, setGalleryLoaded] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const resetState = useCallback(() => {
setTab('upload');
setUrl('');
setUploading(false);
setDragOver(false);
setGallery([]);
setGalleryPage(1);
setGalleryTotalPages(1);
setGalleryLoading(false);
setGalleryLoaded(false);
setSelected(new Set());
}, []);
useEffect(() => {
if (open) resetState();
}, [open, resetState]);
const handleOpenChange = (next: boolean) => {
if (!next) resetState();
onOpenChange(next);
};
const finishInsert = (urls: string[]) => {
if (!urls.length) return;
onInsert(urls);
handleOpenChange(false);
};
const uploadFiles = async (files: File[]) => {
const images = files.filter(f => f.type.startsWith('image/'));
if (!images.length) {
notify.warning('请选择图片文件(jpeg / png / gif / webp)');
return;
}
setUploading(true);
const urls: string[] = [];
try {
for (const file of images) {
try {
const { url: uploaded } = await api.uploadPostImage(file);
urls.push(uploaded);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
if (urls.length) finishInsert(urls);
} finally {
setUploading(false);
}
};
const onFileChange = (list: FileList | null) => {
if (!list?.length) return;
void uploadFiles([...list]);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragOver(false);
if (uploading) return;
void uploadFiles([...e.dataTransfer.files]);
};
const handleLinkInsert = () => {
const next = url.trim();
if (!next) {
notify.warning('请输入图片地址');
return;
}
if (!isValidImageSrc(next)) {
notify.warning('请使用 http(s) 外链或本站以 / 开头的路径');
return;
}
finishInsert([next]);
};
const loadGallery = useCallback(async (page: number, append: boolean) => {
setGalleryLoading(true);
try {
const res = await api.myPostImages({ page, size: PAGE_SIZE });
setGallery(prev => (append ? [...prev, ...res.files] : res.files));
setGalleryPage(res.page);
setGalleryTotalPages(res.total_pages);
setGalleryLoaded(true);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载图片失败');
setGalleryLoaded(true);
} finally {
setGalleryLoading(false);
}
}, []);
useEffect(() => {
if (open && tab === 'gallery' && !galleryLoaded && !galleryLoading) {
void loadGallery(1, false);
}
}, [open, tab, galleryLoaded, galleryLoading, loadGallery]);
const toggleSelect = (itemUrl: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(itemUrl)) next.delete(itemUrl);
else next.add(itemUrl);
return next;
});
};
const handleGalleryInsert = () => {
if (!selected.size) {
notify.warning('请先选择图片');
return;
}
// 保持网格出现顺序
const urls = gallery.filter(f => selected.has(f.url)).map(f => f.url);
finishInsert(urls);
};
const tabs: { id: PickerTab; label: string; icon: typeof Upload }[] = [
{ id: 'upload', label: '上传', icon: Upload },
{ id: 'link', label: '链接', icon: Link2 },
{ id: 'gallery', label: '我的图片', icon: Images },
];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="article-image-picker">
<DialogHeader>
<DialogTitle>插入图片</DialogTitle>
<DialogDescription>
上传本地文件、粘贴链接,或从已上传图库中选择。
</DialogDescription>
</DialogHeader>
<div className="article-image-picker__tabs" role="tablist">
{tabs.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
className={`article-image-picker__tab${tab === t.id ? ' is-active' : ''}`}
onClick={() => setTab(t.id)}
disabled={uploading}
>
<Icon size={14} aria-hidden />
{t.label}
</button>
);
})}
</div>
{tab === 'upload' && (
<div className="article-image-picker__panel">
<div
className={`article-image-picker__drop${dragOver ? ' is-dragover' : ''}${uploading ? ' is-busy' : ''}`}
onDragOver={e => {
e.preventDefault();
if (!uploading) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef.current?.click();
}
}}
>
{uploading ? (
<>
<Loader2 size={28} className="article-image-picker__spin" />
<p>正在上传…</p>
</>
) : (
<>
<Upload size={28} />
<p>拖拽图片到此处,或点击选择文件</p>
<span>支持 jpeg / png / gif / webp;多选将并排成图组</span>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
className="sr-only"
onChange={e => {
onFileChange(e.target.files);
e.target.value = '';
}}
/>
</div>
)}
{tab === 'link' && (
<div className="article-image-picker__panel">
<Input
type="url"
value={url}
placeholder="https://… 或 /uploads/posts/…"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleLinkInsert();
}
}}
autoFocus
/>
<p className="article-image-picker__hint">
粘贴外链或本站已上传地址,无需重复上传。
</p>
<DialogFooter className="article-image-picker__footer">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
取消
</Button>
<Button type="button" onClick={handleLinkInsert}>
插入
</Button>
</DialogFooter>
</div>
)}
{tab === 'gallery' && (
<div className="article-image-picker__panel">
{galleryLoading && !gallery.length ? (
<div className="article-image-picker__empty">
<Loader2 size={22} className="article-image-picker__spin" />
<span>加载中…</span>
</div>
) : !gallery.length ? (
<div className="article-image-picker__empty">暂无上传记录</div>
) : (
<>
<div className="article-image-picker__grid">
{gallery.map(item => {
const thumb = toPostImageThumbSrc(item.url) || item.url;
const isSel = selected.has(item.url);
return (
<button
key={item.url}
type="button"
className={`article-image-picker__thumb${isSel ? ' is-selected' : ''}`}
title={item.name}
onClick={() => toggleSelect(item.url)}
>
<img src={thumb} alt={item.name} loading="lazy" />
</button>
);
})}
</div>
{galleryPage < galleryTotalPages && (
<div className="article-image-picker__more">
<Button
type="button"
variant="outline"
size="sm"
disabled={galleryLoading}
onClick={() => void loadGallery(galleryPage + 1, true)}
>
{galleryLoading ? '加载中…' : '加载更多'}
</Button>
</div>
)}
</>
)}
<DialogFooter className="article-image-picker__footer">
<span className="article-image-picker__selected-count">
已选 {selected.size}
</span>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
取消
</Button>
<Button type="button" disabled={!selected.size} onClick={handleGalleryInsert}>
插入选中
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,39 +1,172 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { notify } from '@/lib/notify';
import { api } from '@/api/client';
import type { PostItem, SitePageSummary } from '@/api/types';
import { pagePath, postPath } from '@/utils/permalink';
import { useForumLimits } from '@/hooks/useForumLimits';
import { ChevronDown, Loader2 } from 'lucide-react';
export interface ArticleLinkConfirm {
url: string;
text: string;
}
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
initialUrl?: string; initialUrl?: string;
onConfirm: (url: string) => void; initialText?: string;
/** 是否正在编辑已有链接 */
editing?: boolean;
onConfirm: (payload: ArticleLinkConfirm) => void;
onRemove?: () => void; onRemove?: () => void;
} }
/** 文章编辑器链接输入弹窗 */ type SiteHit = {
key: string;
title: string;
url: string;
kind: 'post' | 'page';
};
/** 校验可插入的链接地址:外链或站内绝对路径 */
export function isValidLinkHref(url: string): boolean {
const u = url.trim();
if (!u) return false;
if (u.startsWith('/') && !u.startsWith('//')) return true;
if (u.startsWith('#') && u.length > 1) return true;
try {
const parsed = new URL(u);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const SEARCH_DEBOUNCE_MS = 300;
const POST_PAGE_SIZE = 12;
/** 文章/评论编辑器:插入或编辑链接(含站内内容搜索) */
export function ArticleLinkDialog({ export function ArticleLinkDialog({
open, open,
onOpenChange, onOpenChange,
initialUrl = '', initialUrl = '',
initialText = '',
editing = false,
onConfirm, onConfirm,
onRemove, onRemove,
}: Props) { }: Props) {
const [url, setUrl] = useState(initialUrl); const { limits } = useForumLimits();
const [url, setUrl] = useState('');
const [text, setText] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [posts, setPosts] = useState<PostItem[]>([]);
const [pages, setPages] = useState<SitePageSummary[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (open) setUrl(initialUrl || 'https://'); if (!open) return;
}, [open, initialUrl]); setUrl(initialUrl || '');
setText(initialText || '');
setAdvancedOpen(false);
setQuery('');
setDebouncedQuery('');
setPosts([]);
setPages([]);
setLoaded(false);
}, [open, initialUrl, initialText]);
useEffect(() => {
if (!open || !advancedOpen) return;
const t = window.setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(t);
}, [query, open, advancedOpen]);
const loadSiteHits = useCallback(async (keyword: string) => {
setLoading(true);
try {
const [postsRes, pagesRes] = await Promise.all([
api.posts({
page: 1,
size: POST_PAGE_SIZE,
sort: 'new',
...(keyword ? { keyword, title_only: '1' } : {}),
}),
api.pages(),
]);
setPosts(postsRes.posts || []);
let nextPages = pagesRes.pages || [];
if (keyword) {
const q = keyword.toLowerCase();
nextPages = nextPages.filter(
p => p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q),
);
}
setPages(nextPages.slice(0, POST_PAGE_SIZE));
setLoaded(true);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载站内内容失败');
setLoaded(true);
} finally {
setLoading(false);
}
}, []);
// 仅展开高级选项时再请求站内内容,避免默认打开就打接口
useEffect(() => {
if (!open || !advancedOpen) return;
void loadSiteHits(debouncedQuery);
}, [open, advancedOpen, debouncedQuery, loadSiteHits]);
const hits: SiteHit[] = useMemo(() => {
const postHits: SiteHit[] = posts.map(p => ({
key: `post-${p.id}`,
title: p.title,
url: postPath(p.id, limits),
kind: 'post',
}));
const pageHits: SiteHit[] = pages.map(p => ({
key: `page-${p.slug}`,
title: p.title,
url: pagePath(p.slug, limits),
kind: 'page',
}));
return [...postHits, ...pageHits];
}, [posts, pages, limits]);
const pickHit = (hit: SiteHit) => {
setUrl(hit.url);
// 选站内内容时同步用标题作为链接文字
setText(hit.title);
};
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(url.trim()); const nextUrl = url.trim();
if (!nextUrl) {
notify.warning('请输入网址');
return;
}
if (!isValidLinkHref(nextUrl)) {
notify.warning('请使用 http(s) 外链或本站以 / 开头的路径');
return;
}
onConfirm({
url: nextUrl,
text: text.trim() || '链接文字',
});
onOpenChange(false); onOpenChange(false);
}; };
@@ -41,25 +174,119 @@ export function ArticleLinkDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-link-dialog"> <DialogContent className="article-link-dialog">
<DialogHeader> <DialogHeader>
<DialogTitle>插入链接</DialogTitle> <DialogTitle>插入或编辑链接</DialogTitle>
<DialogDescription>输入完整 URL,留空并确认可移除已有链接。</DialogDescription>
</DialogHeader> </DialogHeader>
<Input
type="url" <section className="article-link-dialog__section">
value={url} <div className="article-link-dialog__field">
placeholder="https://" <Label htmlFor="article-link-url">网址</Label>
onChange={e => setUrl(e.target.value)} <Input
onKeyDown={e => { id="article-link-url"
if (e.key === 'Enter') { type="url"
e.preventDefault(); value={url}
handleConfirm(); placeholder="https://… 或 /post/123"
} onChange={e => setUrl(e.target.value)}
}} onKeyDown={e => {
autoFocus if (e.key === 'Enter') {
/> e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
</div>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-text">链接文字</Label>
<Input
id="article-link-text"
value={text}
placeholder="显示在正文中的文字"
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
/>
</div>
</section>
<section className="article-link-dialog__section article-link-dialog__advanced">
<button
type="button"
className={`article-link-dialog__advanced-toggle${advancedOpen ? ' is-open' : ''}`}
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen(v => !v)}
>
<span>高级选项</span>
<ChevronDown size={16} aria-hidden className="article-link-dialog__advanced-chevron" />
</button>
{advancedOpen ? (
<div className="article-link-dialog__advanced-body">
<h3 className="article-link-dialog__section-title">链接到站点中的内容</h3>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-search">搜索</Label>
<Input
id="article-link-search"
type="search"
value={query}
placeholder="搜索帖子或单页…"
onChange={e => setQuery(e.target.value)}
/>
</div>
<div className="article-link-dialog__hits">
<p className="article-link-dialog__hits-hint">
{debouncedQuery
? `搜索「${debouncedQuery}」`
: '未指定搜索条件。自动显示最近发布条目。'}
</p>
{loading && !loaded ? (
<div className="article-link-dialog__hits-empty">
<Loader2 size={18} className="article-link-dialog__spin" />
<span>加载中…</span>
</div>
) : !hits.length ? (
<div className="article-link-dialog__hits-empty">暂无匹配内容</div>
) : (
<ul className="article-link-dialog__hit-list">
{hits.map(hit => (
<li key={hit.key}>
<button
type="button"
className={`article-link-dialog__hit${url === hit.url ? ' is-selected' : ''}`}
onClick={() => pickHit(hit)}
>
<span className="article-link-dialog__hit-title">{hit.title}</span>
<span className="article-link-dialog__hit-kind">
{hit.kind === 'post' ? '帖子' : '单页'}
</span>
</button>
</li>
))}
</ul>
)}
{loading && loaded ? (
<div className="article-link-dialog__hits-loading">
<Loader2 size={14} className="article-link-dialog__spin" />
</div>
) : null}
</div>
</div>
) : null}
</section>
<DialogFooter className="article-link-dialog__footer"> <DialogFooter className="article-link-dialog__footer">
{onRemove && initialUrl ? ( {editing && onRemove ? (
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}> <Button
type="button"
variant="outline"
className="article-link-dialog__remove"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
移除链接 移除链接
</Button> </Button>
) : null} ) : null}
@@ -67,7 +294,7 @@ export function ArticleLinkDialog({
取消 取消
</Button> </Button>
<Button type="button" onClick={handleConfirm}> <Button type="button" onClick={handleConfirm}>
确定 {editing ? '更新链接' : '添加链接'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,54 @@
import { Node, mergeAttributes } from '@tiptap/core';
/** 贴纸资源路径:评论、私信、发帖共用 /stickers/ */
export function isStickerSrc(src: string | null | undefined): boolean {
return typeof src === 'string' && src.includes('/stickers/');
}
/**
* 行内表情贴纸。文章 Image 是 block,不能拿来插表情,否则会独自占一段。
* 解析 HTML 时优先于普通 img,避免贴纸被收成通栏大图。
*/
export const ArticleSticker = Node.create({
name: 'sticker',
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
priority: 60,
addAttributes() {
return {
src: { default: null },
alt: { default: '' },
};
},
parseHTML() {
return [
{
tag: 'img[src]',
getAttrs: (node) => {
if (typeof node === 'string') return false;
const src = node.getAttribute('src') || '';
if (!isStickerSrc(src)) return false;
return {
src,
alt: node.getAttribute('alt') || '',
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'img',
mergeAttributes(HTMLAttributes, {
class: 'article-sticker',
draggable: 'false',
}),
];
},
});

View File

@@ -36,17 +36,58 @@ export default function StickerPicker({ onSelect }: Props) {
}, [active]); }, [active]);
useEffect(() => { useEffect(() => {
const el = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[focusIndex]; if (loading || stickers.length === 0) return;
el?.focus(); gridRef.current?.focus();
}, [focusIndex, stickers]); }, [loading, stickers]);
const onKeyDown = useCallback((e: React.KeyboardEvent) => { const onKeyDown = useCallback((e: React.KeyboardEvent) => {
const cols = 8; const items = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
if (e.key === 'ArrowRight') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + 1)); } if (!items?.length) return;
else if (e.key === 'ArrowLeft') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - 1)); }
else if (e.key === 'ArrowDown') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + cols)); } const moveTo = (next: number) => {
else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - cols)); } e.preventDefault();
else if (e.key === 'Enter' || e.key === ' ') { const i = Math.max(0, Math.min(items.length - 1, next));
setFocusIndex(i);
items[i]?.focus();
};
if (e.key === 'ArrowRight') {
moveTo(focusIndex + 1);
return;
}
if (e.key === 'ArrowLeft') {
moveTo(focusIndex - 1);
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// 颜文字宽度不固定,按视觉行列找下一格,避免按固定 8 列错位
e.preventDefault();
const cur = items[focusIndex];
if (!cur) return;
const cr = cur.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
const dir = e.key === 'ArrowDown' ? 1 : -1;
let best = -1;
let bestScore = Infinity;
items.forEach((el, i) => {
if (i === focusIndex) return;
const r = el.getBoundingClientRect();
const dy = (r.top + r.height / 2) - cy;
if (dy * dir <= 6) return;
const score = Math.abs(dy) * 24 + Math.abs((r.left + r.width / 2) - cx);
if (score < bestScore) {
bestScore = score;
best = i;
}
});
if (best >= 0) {
setFocusIndex(best);
items[best]?.focus();
}
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
const s = stickers[focusIndex]; const s = stickers[focusIndex];
if (s) onSelect(s); if (s) onSelect(s);
@@ -73,6 +114,7 @@ export default function StickerPicker({ onSelect }: Props) {
ref={gridRef} ref={gridRef}
className="sticker-picker-grid" className="sticker-picker-grid"
role="listbox" role="listbox"
tabIndex={0}
aria-label={`${active}贴纸`} aria-label={`${active}贴纸`}
aria-activedescendant={`${autoId}-opt-${focusIndex}`} aria-activedescendant={`${autoId}-opt-${focusIndex}`}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -82,33 +124,35 @@ export default function StickerPicker({ onSelect }: Props) {
) : stickers.length === 0 ? ( ) : stickers.length === 0 ? (
<div className="sticker-picker-loading">暂无贴纸</div> <div className="sticker-picker-loading">暂无贴纸</div>
) : ( ) : (
stickers.map((s, i) => ( stickers.map((s, i) => {
<button const isText = s.type === 'text' && !!s.text;
key={s.id} return (
id={`${autoId}-opt-${i}`} <button
type="button" key={s.id}
role="option" id={`${autoId}-opt-${i}`}
tabIndex={focusIndex === i ? 0 : -1} type="button"
aria-selected={focusIndex === i} role="option"
aria-label={s.name} tabIndex={focusIndex === i ? 0 : -1}
className="sticker-picker-item" aria-selected={focusIndex === i}
onClick={() => onSelect(s)} aria-label={s.name}
onFocus={() => setFocusIndex(i)} className={isText ? 'sticker-picker-item sticker-picker-item--text' : 'sticker-picker-item sticker-picker-item--image'}
> onClick={() => onSelect(s)}
{s.type === 'text' && s.text ? ( onFocus={() => setFocusIndex(i)}
<span className="sticker-picker-text">{s.text}</span> >
) : ( {isText ? (
<img <span className="sticker-picker-text">{s.text}</span>
src={s.url} ) : (
alt={s.name} <img
width={32} src={s.url}
height={32} alt={s.name}
style={{ width: 32, height: 32, objectFit: 'contain' }} width={32}
loading="lazy" height={32}
/> loading="lazy"
)} />
</button> )}
)) </button>
);
})
)} )}
</div> </div>
</div> </div>

View File

@@ -15,10 +15,7 @@ export async function loadHotStickers(): Promise<Sticker[]> {
const allEmoji = getAllStickers(); const allEmoji = getAllStickers();
const all = [...allEmoji, ...KAOMOJI_STICKERS]; const all = [...allEmoji, ...KAOMOJI_STICKERS];
return all return all
.filter((s) => { .filter((s) => s.aliases?.some((a) => HOT_KEYWORDS.includes(a)) || HOT_KEYWORDS.includes(s.name))
if (s.category === '颜文字') return true;
return s.aliases?.some((a) => HOT_KEYWORDS.includes(a)) || HOT_KEYWORDS.includes(s.name);
})
.slice(0, 30) .slice(0, 30)
.map((s) => ({ ...s, category: '热门' as const })); .map((s) => ({ ...s, category: '热门' as const }));
} }

View File

@@ -3,30 +3,54 @@ import type { Sticker } from './index';
/** 颜文字贴纸 — 纯文本类型,选择器和编辑器中均作为纯文本显示 */ /** 颜文字贴纸 — 纯文本类型,选择器和编辑器中均作为纯文本显示 */
export const KAOMOJI_STICKERS: Sticker[] = [ export const KAOMOJI_STICKERS: Sticker[] = [
{ id: 'km-happy', name: '开心', category: '颜文字', type: 'text', text: '(ノ´∀`)ノ', aliases: ['哈哈', '开心'] }, { id: 'km-wave-half', name: '勉强挥手', category: '颜文字', type: 'text', text: '( ̄▽ ̄)ノ', aliases: ['挥手', '嗨'] },
{ id: 'km-laugh', name: '大笑', category: '颜文字', type: 'text', text: '(≧∇≦)ノ', aliases: ['哈哈哈', '笑死'] }, { id: 'km-shrug-ascii', name: '摊手', category: '颜文字', type: 'text', text: '¯\\_(ツ)_/¯', aliases: ['摊手', '无奈'] },
{ id: 'km-cry', name: '哭', category: '颜文字', type: 'text', text: '(´;ω;`)', aliases: ['呜呜', '伤心'] }, { id: 'km-eyeroll', name: '翻白眼', category: '颜文字', type: 'text', text: '(¬_¬)', aliases: ['白眼', '嫌弃'] },
{ id: 'km-angry', name: '生气', category: '颜文字', type: 'text', text: 'ヽ(`⌒´)ノ', aliases: ['怒', '气死'] }, { id: 'km-speechless', name: '无语凝噎', category: '颜文字', type: 'text', text: '(;一_一)', aliases: ['无语', '沉默'] },
{ id: 'km-shrug', name: '无奈', category: '颜文字', type: 'text', text: '╮(°-°)╭', aliases: ['呵呵', '无语'] }, { id: 'km-shock-idle', name: '震惊但不想管', category: '颜文字', type: 'text', text: '( ゚д゚)', aliases: ['震惊', '惊讶'] },
{ id: 'km-determined', name: '加油', category: '颜文字', type: 'text', text: '(๑•̀ㅁ•́ฅ)', aliases: ['冲', '奥利给'] }, { id: 'km-dead-inside', name: '心死', category: '颜文字', type: 'text', text: '(。_。)', aliases: ['心死', '无力'] },
{ id: 'km-sad', name: '难过', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['失落', '低落'] }, { id: 'km-lazy', name: '懒得动', category: '颜文字', type: 'text', text: '( ˘ω˘ )', aliases: ['懒', '摆烂'] },
{ id: 'km-sparkle', name: '兴奋', category: '颜文字', type: 'text', text: '(ノ´ヮ`)ノ*: ・゚', aliases: ['太棒了', '耶'] }, { id: 'km-awkward-smile', name: '尴尬微笑', category: '颜文字', type: 'text', text: '( ̄ω ̄;)', aliases: ['尴尬', '呵呵'] },
{ id: 'km-tear', name: '泪奔', category: '颜文字', type: 'text', text: '(╥﹏╥)', aliases: ['泪流', '呜呜'] }, { id: 'km-sob', name: '哭到抽搐', category: '颜文字', type: 'text', text: '(´;ω;`)', aliases: ['哭', '大哭'] },
{ id: 'km-love', name: '喜欢', category: '颜文字', type: 'text', text: '(◕ᴗ◕✿)', aliases: ['爱', '心动'] }, { id: 'km-grievance', name: '委屈巴巴', category: '颜文字', type: 'text', text: '(๑•́ ₃ •̀๑)', aliases: ['委屈', '嘤嘤'] },
{ id: 'km-cool', name: '酷', category: '颜文字', type: 'text', text: '(⌐■_■)', aliases: ['帅', '墨镜'] }, { id: 'km-blush-blur', name: '害羞到糊掉', category: '颜文字', type: 'text', text: '(⁄ ⁄•⁄ω⁄•⁄ ⁄)', aliases: ['害羞', '脸红'] },
{ id: 'km-stare', name: '盯', category: '颜文字', type: 'text', text: 'ಠ_ಠ', aliases: ['凝视', '盯着看'] }, { id: 'km-grit', name: '咬牙切齿', category: '颜文字', type: 'text', text: '(╬  ̄皿 ̄)', aliases: ['怒', '生气'] },
{ id: 'km-tableflip', name: '掀桌', category: '颜文字', type: 'text', text: '(╯°□°)╯︵ ┻━┻', aliases: ['掀桌', '愤怒'] }, { id: 'km-short-rage', name: '暴怒短号', category: '颜文字', type: 'text', text: '(`Д´)', aliases: ['怒', '气死'] },
{ id: 'km-bow', name: '拜托', category: '颜文字', type: 'text', text: '(人・ω・)💦', aliases: ['求求', '拜托了'] }, { id: 'km-fist-rage', name: '气到挥拳', category: '颜文字', type: 'text', text: '٩(๑`^´๑)۶', aliases: ['怒', '挥拳'] },
{ id: 'km-proud', name: '得意', category: '颜文字', type: 'text', text: '( ̄▽ ̄)"', aliases: ['嘿嘿', '自满'] }, { id: 'km-grit-spark', name: '憋屈但要干', category: '颜文字', type: 'text', text: '(๑•̀ㅂ•́)و✧', aliases: ['冲', '加油'] },
{ id: 'km-sleep', name: '困', category: '颜文字', type: 'text', text: '(-ω-)Zzz', aliases: ['睡觉', '晚安'] }, { id: 'km-weep', name: '哭唧唧', category: '颜文字', type: 'text', text: '( ˃̣̣̥ω˂̣̣̥ )', aliases: ['哭', '呜呜'] },
{ id: 'km-wave', name: '招手', category: '颜文字', type: 'text', text: '(´・ω・)ノ', aliases: ['你好', '拜拜'] }, { id: 'km-scamper', name: '撒欢跑走', category: '颜文字', type: 'text', text: 'ᕕ( ᐛ )ᕗ', aliases: ['跑', '溜了'] },
{ id: 'km-wink', name: '眨眼', category: '颜文字', type: 'text', text: '(◠‿◠)', aliases: ['抛媚眼', '嘿嘿'] }, { id: 'km-star-throw', name: '丢星星', category: '颜文字', type: 'text', text: '(ノ≧∀≦)ノ ‥…━━━★', aliases: ['耶', '星星'] },
{ id: 'km-sorry', name: '抱歉', category: '颜文字', type: 'text', text: 'm(._.)m', aliases: ['对不起', '跪了'] }, { id: 'km-flee', name: '落荒而逃', category: '颜文字', type: 'text', text: 'ε=ε=ε=┌(;*´Д`)ノ', aliases: ['逃', '溜'] },
{ id: 'km-doubt', name: '疑惑', category: '颜文字', type: 'text', text: '(╬ Ò _ Ó)', aliases: ['什么', '???'] }, { id: 'km-unflip', name: '把桌摆回去', category: '颜文字', type: 'text', text: '┬─┬ノ( º _ ºノ)', aliases: ['摆桌', '冷静'] },
{ id: 'km-hungry', name: '饿了', category: '颜文字', type: 'text', text: '(๑´ㅂ`๑)', aliases: ['想吃', '吃货'] }, { id: 'km-flip-hard', name: '狠掀桌', category: '颜文字', type: 'text', text: '(┛ಠ_ಠ)┛彡┻━┻', aliases: ['掀桌', '怒'] },
{ id: 'km-gameover', name: 'GG', category: '颜文字', type: 'text', text: '(╯︿╰﹀ )', aliases: ['GG', '完了'] }, { id: 'km-victory-l', name: '胜利举手', category: '颜文字', type: 'text', text: '┏(^0^)┛', aliases: ['胜利', '耶'] },
{ id: 'km-gift', name: '送花', category: '颜文字', type: 'text', text: '(✿◠‿◠)', aliases: ['送花', '谢谢'] }, { id: 'km-victory-r', name: '对面胜利', category: '颜文字', type: 'text', text: '┗(^0^)┓', aliases: ['胜利', '嗨'] },
{ id: 'km-clap', name: '鼓掌', category: '颜文字', type: 'text', text: 'ヾ(´▽`;)ゝ', aliases: ['呱唧', '鼓掌'] }, { id: 'km-point', name: '指你呢', category: '颜文字', type: 'text', text: '(☞゚ヮ゚)☞', aliases: ['指', '就是你'] },
{ id: 'km-cheer', name: '加油', category: '颜文字', type: 'text', text: '\(^ω^\)', aliases: ['冲鸭', 'go'] }, { id: 'km-point-back', name: '指回去', category: '颜文字', type: 'text', text: '☜(゚ヮ゚☜)', aliases: ['指回去', '你才'] },
{ id: 'km-please', name: '拜托了', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['嘤嘤', '求求了'] }, { id: 'km-cat', name: '猫', category: '颜文字', type: 'text', text: '(=^・ω・^=)', aliases: ['猫', '喵'] },
{ id: 'km-cat-round', name: '圆眼猫', category: '颜文字', type: 'text', text: '(ΦωΦ)', aliases: ['猫', '圆眼'] },
{ id: 'km-bear', name: '熊', category: '颜文字', type: 'text', text: 'ʕ•ᴥ•ʔ', aliases: ['熊', '抱抱'] },
{ id: 'km-flower-ear', name: '花耳', category: '颜文字', type: 'text', text: '◕‿◕✿', aliases: ['花', '可爱'] },
{ id: 'km-angry-bird', name: '怒鸟', category: '颜文字', type: 'text', text: '(ꐦ°᷄д°᷅)', aliases: ['怒', '生气'] },
{ id: 'km-smug-wolf', name: '狼尾得意', category: '颜文字', type: 'text', text: '( •̀ ω •́ )✧', aliases: ['得意', '酷'] },
{ id: 'km-cat-wave', name: '猫招手', category: '颜文字', type: 'text', text: '~(=^‥^)ノ', aliases: ['猫', '招手'] },
{ id: 'km-hehe', name: '呵呵', category: '颜文字', type: 'text', text: '( ´_ゝ`)', aliases: ['呵呵', '滑稽'] },
{ id: 'km-server-down', name: '服务器炸了', category: '颜文字', type: 'text', text: '(;´Д`)', aliases: ['炸了', '宕机'] },
{ id: 'km-double-shock', name: '双重震惊', category: '颜文字', type: 'text', text: '(゚Д゚≡゚д゚)!?', aliases: ['震惊', '惊讶'] },
{ id: 'km-cold-sweat', name: '冷汗惊恐', category: '颜文字', type: 'text', text: 'Σ(°△°|||)︴', aliases: ['惊恐', '惊讶'] },
{ id: 'km-hang', name: '宕机', category: '颜文字', type: 'text', text: '(;°○° )', aliases: ['宕机', '卡死'] },
{ id: 'km-pass-box', name: '递箱子', category: '颜文字', type: 'text', text: '( ゚∀゚)つ□', aliases: ['递', '补丁'] },
{ id: 'km-shoulder', name: '拍对方肩', category: '颜文字', type: 'text', text: '( ´▽`)σ)Д`)', aliases: ['拍肩', '兄弟'] },
{ id: 'km-silent-crash', name: '无声崩溃', category: '颜文字', type: 'text', text: '(-_-;)・・・', aliases: ['崩溃', '尴尬'] },
{ id: 'km-blank', name: '呆滞', category: '颜文字', type: 'text', text: '(。ŏ_ŏ)', aliases: ['发呆', '呆'] },
{ id: 'km-side-eye', name: '眯眼嫌弃', category: '颜文字', type: 'text', text: '(๑¯ω¯๑)', aliases: ['嫌弃', '眯眼'] },
{ id: 'km-lick', name: '舔嘴', category: '颜文字', type: 'text', text: '(๑´ڡ`๑)', aliases: ['好吃', '馋'] },
{ id: 'km-round-laugh', name: '圆滚滚笑', category: '颜文字', type: 'text', text: '( ˶˚ ᗨ ˚˶ )', aliases: ['哈哈', '大笑'] },
{ id: 'km-hug-ask', name: '求抱抱', category: '颜文字', type: 'text', text: '(っ˘̩╭╮˘̩)っ', aliases: ['抱抱', '求抱'] },
{ id: 'km-rage-yi', name: '暴怒益字', category: '颜文字', type: 'text', text: '(╬ಠ益ಠ)', aliases: ['怒', '生气'] },
{ id: 'km-fight', name: '对线准备', category: '颜文字', type: 'text', text: "(ง'̀-'́)ง", aliases: ['对线', '来战'] },
{ id: 'km-cat-minimal', name: '极简猫', category: '颜文字', type: 'text', text: 'ᓚᘏᗢ', aliases: ['猫', '喵'] },
{ id: 'km-pounce', name: '飞扑拥抱', category: '颜文字', type: 'text', text: '(づ。◕‿‿◕。)づ', aliases: ['拥抱', '爱'] },
{ id: 'km-suspect', name: '怀疑人生', category: '颜文字', type: 'text', text: '( ◔ ʖ̯ ◔ )', aliases: ['怀疑', '思考'] },
]; ];

View File

@@ -1,6 +1,9 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { User } from '../api/types'; import type { User } from '../api/types';
import { clearAllFeedCache } from '../utils/feedCache';
import { clearSessionSnapshots } from '../utils/sessionPageCache';
import { peekAuthSeed } from '../utils/authBoot';
interface AuthCtx { interface AuthCtx {
user: User | null; user: User | null;
@@ -15,8 +18,12 @@ const AuthContext = createContext<AuthCtx>({
}); });
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); // peek 不清种子,StrictMode 重挂 / initializer 双调仍与 SSR 同构
const [loading, setLoading] = useState(true); const [user, setUser] = useState<User | null>(() => {
const s = peekAuthSeed();
return s.hasSeed ? s.user : null;
});
const [loading, setLoading] = useState(() => !peekAuthSeed().hasSeed);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -29,8 +36,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
// 初始化只拉一次用户信息 // SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求;
useEffect(() => { refresh(); }, [refresh]); // 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
useEffect(() => {
if (peekAuthSeed().hasSeed) return;
void refresh();
}, [refresh]);
const prevUserId = useRef<number | null | 'init'>('init');
useEffect(() => {
if (loading) return;
const id = user?.id ?? null;
if (prevUserId.current === 'init') {
prevUserId.current = id;
return;
}
if (prevUserId.current !== id) {
prevUserId.current = id;
clearSessionSnapshots();
clearAllFeedCache();
}
}, [user, loading]);
const logout = async () => { const logout = async () => {
await api.logout(); await api.logout();

View File

@@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import type { CheckInStatus } from '../api/types'; import type { CheckInStatus } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
/** 签到会话快照键(与 prefetchRoute 共用) */
export function checkInCacheKey(userId: number) {
return `checkin:${userId}`;
}
/** 每日签到状态与操作(侧栏、积分页等复用) */ /** 每日签到状态与操作(侧栏、积分页等复用) */
export function useCheckIn(enabled = true) { export function useCheckIn(enabled = true) {
const { user, refresh } = useAuth(); const { user, refresh } = useAuth();
const [status, setStatus] = useState<CheckInStatus | null>(null); const userId = user?.id;
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<CheckInStatus | null>(() => {
if (!userId || !enabled) return null;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) ?? null;
});
const [loading, setLoading] = useState(() => {
if (!userId || !enabled) return false;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) === undefined;
});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(() => { const load = useCallback((opts?: { force?: boolean }) => {
if (!user || !enabled) { if (!userId || !enabled) {
setStatus(null); setStatus(null);
setLoading(false); setLoading(false);
return; return;
} }
const key = checkInCacheKey(userId);
if (!opts?.force) {
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
}
setLoading(true); setLoading(true);
api.checkInStatus() api.checkInStatus()
.then(d => setStatus(d.check_in)) .then((d) => {
.catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败')) setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
})
.catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [user, enabled]); }, [userId, enabled]);
useEffect(() => { useEffect(() => {
load(); if (!userId || !enabled) {
}, [load]); setStatus(null);
setLoading(false);
return;
}
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
setStatus(null);
setLoading(true);
load({ force: true });
}, [userId, enabled, load]);
// 软刷新 commit:仅快照命中才盖;未命中保持旧 UI,后台静默再拉
useEffect(() => {
const applyHitOrSilent = (forceLoading: boolean) => {
if (!userId || !enabled) return;
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
if (forceLoading) {
load({ force: true });
return;
}
// 软刷新未命中:不 setLoading,静默覆盖
api.checkInStatus()
.then((d) => {
setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
setLoading(false);
})
.catch(() => { /* 保持旧 UI */ });
};
const onForce = () => applyHitOrSilent(true);
const onCommit = () => applyHitOrSilent(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [userId, enabled, load]);
const doCheckIn = useCallback(async () => { const doCheckIn = useCallback(async () => {
if (!user) return; if (!userId) return;
setBusy(true); setBusy(true);
try { try {
const r = await api.checkIn(); const r = await api.checkIn();
notify.success(`签到成功,+${r.check_in.today_points} 积分`); notify.success(`签到成功,+${r.check_in.today_points} 积分`);
setSessionSnapshot(checkInCacheKey(userId), r.check_in);
setStatus(r.check_in); setStatus(r.check_in);
await refresh(); await refresh();
} catch (e: unknown) { } catch (e: unknown) {
@@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) {
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [user, refresh]); }, [userId, refresh]);
return { return {
status, status,
loading, loading,
busy, busy,
doCheckIn, doCheckIn,
reload: load, reload: () => load({ force: true }),
}; };
} }

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types'; import type { ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types'; import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
import { getScope } from '../ssr/scope';
const DEFAULT_LIMITS: ForumLimitsPublic = { const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128, post_title_max: 128,
@@ -20,12 +21,17 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: true, aside_show_friend_links: true,
aside_show_showcase: false,
aside_widgets: DEFAULT_ASIDE_WIDGETS, aside_widgets: DEFAULT_ASIDE_WIDGETS,
nav_show_friend_links: true, nav_show_friend_links: true,
footer_show_friend_links: true, footer_show_friend_links: true,
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title', feed_list_style: 'title',
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false, permalink_enabled: false,
permalink_ext: 'html', permalink_ext: 'html',
monitor_pageview: false,
}; };
let cached: ForumLimitsPublic | null = null; let cached: ForumLimitsPublic | null = null;
@@ -33,6 +39,11 @@ let inflight: Promise<ForumLimitsPublic> | null = null;
let cacheEpoch = 0; let cacheEpoch = 0;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** 当前生效的论坛限制:SSR 读请求作用域,客户端读模块缓存 */
function currentLimits(): ForumLimitsPublic | null {
return getScope()?.limits ?? cached;
}
function fetchLimits(): Promise<ForumLimitsPublic> { function fetchLimits(): Promise<ForumLimitsPublic> {
if (cached) return Promise.resolve(cached); if (cached) return Promise.resolve(cached);
if (inflight) return inflight; if (inflight) return inflight;
@@ -48,8 +59,9 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
/** 获取前台可见的论坛限制配置 */ /** 获取前台可见的论坛限制配置 */
export function useForumLimits() { export function useForumLimits() {
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS); const [limits, setLimits] = useState<ForumLimitsPublic>(currentLimits() ?? DEFAULT_LIMITS);
const [loading, setLoading] = useState(!cached); const [loading, setLoading] = useState(!currentLimits());
const [epoch, setEpoch] = useState(cacheEpoch); const [epoch, setEpoch] = useState(cacheEpoch);
useEffect(() => { useEffect(() => {
@@ -75,6 +87,32 @@ export function useForumLimits() {
return { limits, loading }; return { limits, loading };
} }
/** 冷启动 / 预热:确保 limits 已写入模块缓存;force 时重拉并在成功后通知 hook */
export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promise<ForumLimitsPublic> {
if (opts?.force) {
cached = null;
inflight = null;
}
const limits = await fetchLimits();
if (opts?.force) {
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
return limits;
}
/** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
const scope = getScope();
if (scope) {
scope.limits = limits;
return;
}
cached = limits;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */ /** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateForumLimitsCache() { export function invalidateForumLimitsCache() {
cached = null; cached = null;
@@ -84,5 +122,5 @@ export function invalidateForumLimitsCache() {
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */ /** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
export function getCachedForumLimits(): ForumLimitsPublic { export function getCachedForumLimits(): ForumLimitsPublic {
return cached ?? DEFAULT_LIMITS; return currentLimits() ?? DEFAULT_LIMITS;
} }

View File

@@ -1,62 +0,0 @@
import { useEffect, type RefObject } from 'react';
/** 判断元素是否可在指定方向继续滚动 */
function canElementScroll(el: HTMLElement, deltaY: number): boolean {
const { overflowY } = getComputedStyle(el);
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
return false;
}
if (el.scrollHeight <= el.clientHeight + 1) return false;
if (deltaY < 0) return el.scrollTop > 0;
return el.scrollTop + el.clientHeight < el.scrollHeight - 1;
}
/** 从目标节点向上查找首个可滚动容器 */
function findScrollable(start: HTMLElement | null, deltaY: number, boundary: HTMLElement): HTMLElement | null {
let el = start;
while (el && el !== boundary) {
if (canElementScroll(el, deltaY)) return el;
el = el.parentElement;
}
return null;
}
/**
* 文章页:鼠标在页面任意位置滚轮时,统一滚动主内容区。
* 保留 textarea、表情面板等主内容区内部可滚动元素的独立滚动。
*/
export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, enabled = true) {
useEffect(() => {
if (!enabled) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const root = scrollEl.closest('.app-shell') as HTMLElement | null;
if (!root) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
if (e.ctrlKey || e.metaKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return;
const inner = findScrollable(target, e.deltaY, root);
// 主内容区内部嵌套滚动(如 textarea、表情面板)保留原生行为
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
// 主内容区外的独立滚动区(如右侧目录)保留原生行为,避免滚轮被抢走
if (inner && !scrollEl.contains(inner)) return;
// 鼠标已在主滚动容器上时,交给浏览器原生处理
if (inner === scrollEl) return;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += e.deltaY;
if (scrollEl.scrollTop !== prev) {
e.preventDefault();
}
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [scrollRef, enabled]);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { api } from '../api/client';
import { useForumLimits } from './useForumLimits';
const DEDUPE_MS = 800;
function shouldSkipPath(pathname: string) {
const p = pathname.toLowerCase();
return (
p === '/admin' ||
p.startsWith('/admin/') ||
p === '/login' ||
p === '/register' ||
p === '/forgot-password' ||
p.startsWith('/oauth')
);
}
/** 主站布局:路由变化时上报第一方 pageview(采集关闭则不请求) */
export function useMonitorPageview() {
const loc = useLocation();
const { limits } = useForumLimits();
const lastRef = useRef<{ key: string; at: number }>({ key: '', at: 0 });
const enabled = !!limits.monitor_pageview;
useEffect(() => {
if (!enabled) return;
if (shouldSkipPath(loc.pathname)) return;
const path = `${loc.pathname}${loc.search || ''}`;
const now = Date.now();
if (lastRef.current.key === path && now - lastRef.current.at < DEDUPE_MS) {
return;
}
lastRef.current = { key: path, at: now };
const referrer = typeof document !== 'undefined' ? document.referrer || '' : '';
void api.monitorPageview({ path, referrer });
}, [enabled, loc.pathname, loc.search]);
}

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { buildHomeUrl } from '../components/FeedSortBar'; import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache'; import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink'; import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
@@ -190,7 +191,7 @@ export function usePostSearch(
} }
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId }); saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
nav(target); void transitionTo(nav, target);
return true; return true;
}, [nav, params, loc.pathname, limits, buildUrl]); }, [nav, params, loc.pathname, limits, buildUrl]);

Some files were not shown because too many files have changed in this diff Show More