From 2c06eaccb2efbbf23a39499fb4ec0433f7d1fe4c Mon Sep 17 00:00:00 2001 From: freefire Date: Mon, 3 Aug 2026 18:18:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=91=E5=B8=96=E9=95=BF?= =?UTF-8?q?=E6=96=87=E5=B8=83=E5=B1=80=E4=B8=8E=E6=89=8B=E6=9C=BA=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E5=88=B7=E6=96=B0=EF=BC=8C=E5=8F=91=E7=89=88=E5=90=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BD=BD=E5=A4=B1=E6=95=88=20chunk?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=95=B4=E7=90=86=E7=AB=99=E5=8A=A1=E6=96=87?= =?UTF-8?q?=E6=A1=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/site-posts/01-welcome.md | 55 ------ docs/site-posts/02-getting-started.md | 87 --------- docs/site-posts/03-features.md | 89 ---------- docs/site-posts/04-deployment.md | 68 ------- docs/site-posts/README.md | 29 ++- docs/site-posts/about-jiang13.md | 185 +++++++++++++++++++ docs/site-posts/community-guidelines.md | 30 ++++ docs/site-posts/feedback.md | 26 +++ docs/site-posts/optional.md | 10 ++ embed_static/embed.go | 7 +- embed_static/spa_meta.go | 2 + frontend/src/App.tsx | 48 ++--- frontend/src/components/AppRouteError.tsx | 60 +++++++ frontend/src/components/ArticleEditor.tsx | 42 ++++- frontend/src/components/ErrorBoundary.tsx | 24 ++- frontend/src/components/PullToRefresh.tsx | 205 ++++++++++++++++++++++ frontend/src/pages/ComposePage.tsx | 2 + frontend/src/styles/global.css | 143 +++++++++++++-- frontend/src/utils/chunkLoad.ts | 34 ++++ frontend/src/utils/lazyWithRetry.ts | 25 +++ 20 files changed, 811 insertions(+), 360 deletions(-) delete mode 100644 docs/site-posts/01-welcome.md delete mode 100644 docs/site-posts/02-getting-started.md delete mode 100644 docs/site-posts/03-features.md delete mode 100644 docs/site-posts/04-deployment.md create mode 100644 docs/site-posts/about-jiang13.md create mode 100644 docs/site-posts/community-guidelines.md create mode 100644 docs/site-posts/feedback.md create mode 100644 docs/site-posts/optional.md create mode 100644 frontend/src/components/AppRouteError.tsx create mode 100644 frontend/src/components/PullToRefresh.tsx create mode 100644 frontend/src/utils/chunkLoad.ts create mode 100644 frontend/src/utils/lazyWithRetry.ts diff --git a/docs/site-posts/01-welcome.md b/docs/site-posts/01-welcome.md deleted file mode 100644 index 55b7c3d..0000000 --- a/docs/site-posts/01-welcome.md +++ /dev/null @@ -1,55 +0,0 @@ -# 欢迎来到本站 - -> 建议标题:欢迎来到本站 -> 建议标签:`站务` `介绍` -> 建议操作:置顶(可选精华) - ---- - -你好,欢迎加入本站。 - -这里运行的是开源论坛程序 **姜十三论坛(Jiang13 Forum)**。 -它面向小圈子、团队与同好社群,目标很简单: - -**能聊 · 好看 · 好装** - ---- - -## 这是什么 - -姜十三论坛不是「大而全」的社区平台,而是一个轻量、现代化的论坛: - -- 有板块、帖子、楼层回复,讨论能沉淀、能追溯 -- 界面清爽,信息密度适中,桌面与手机都好用 -- 单个程序即可部署,数据在自己手里 - -技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。 -拷到服务器、启动、注册——就可以开始用。 - ---- - -## 谁适合用 - -| 场景 | 说明 | -| --- | --- | -| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 | -| 兴趣小圈子 | 同好交流、作品分享、活动组织 | -| 项目配套社区 | 可与 Gitea 等工具做 SSO 联动 | -| 个人站长 | 单机可跑,无需云数据库与一堆微服务 | - -如果你需要的是百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。 -如果你要的是 **小而美、自己能装、自己能管**,就对了。 - ---- - -## 本板块说明 - -「站务公告」只发布: - -- 本站介绍与使用指引 -- 规则与站务通知 -- 与姜十三论坛相关的说明 - -日常聊天、提问、分享,请到其他讨论板块。 - -有问题可联系管理员。祝你在这里聊得愉快。 diff --git a/docs/site-posts/02-getting-started.md b/docs/site-posts/02-getting-started.md deleted file mode 100644 index 5e69684..0000000 --- a/docs/site-posts/02-getting-started.md +++ /dev/null @@ -1,87 +0,0 @@ -# 三分钟上手本站 - -> 建议标题:三分钟上手本站 -> 建议标签:`站务` `指南` -> 建议操作:普通发布 - ---- - -这篇给你一条最短路径:从注册到发第一帖,再到(如果是管理员)进入后台。 - ---- - -## 1. 注册与登录 - -1. 打开注册页,填写用户名、邮箱与密码。 -2. 若站点已配置邮件服务,可能需要邮箱验证码。 -3. **第一个注册的用户会自动成为管理员**,无需额外安装向导。 - -登录后,右上角会出现头像与私信入口。 - ---- - -## 2. 认识首页 - -首页通常是三栏布局: - -| 区域 | 内容 | -| --- | --- | -| 左侧 | 板块导航、常用入口 | -| 中间 | 帖子列表(Feed) | -| 右侧 | 热门帖、最新评论等动态 | - -中间列表可按 **最新发帖 / 最新回复 / 热门** 切换。 -支持浅色 / 暗色主题,偏好会记住。 - -手机上侧栏会收起,不影响浏览与发帖。 - ---- - -## 3. 发帖与回复 - -1. 点击「发帖」。 -2. 选择板块,填写标题、标签(可选)与正文。 -3. 正文支持富文本:分段、格式、图片上传等。 -4. 发布后可在帖内回复;评论是 **楼层式** 的,可回复、引用、@。 - -帖子支持点赞与收藏。公开主页上可以看到你的基本资料与动态。 - ---- - -## 4. 个人入口 - -点击头像菜单,常见入口包括: - -- **个人主页**:别人看到的你 -- **账号设置**:昵称、签名、头像、密码 -- **站内私信**:与站内用户往来 -- **我的收藏**:收藏过的帖子 - ---- - -## 5. 管理后台(仅管理员) - -访问路径:`/admin` - -常见能力: - -- 板块、帖子、评论、举报、用户、媒体 -- 系统设置(品牌、限流、敏感词、邮件、OIDC、存储等) -- 数据库备份 - -配置怎么分: - -| 类型 | 在哪里改 | -| --- | --- | -| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) | -| OIDC、邮件、对象存储、品牌文案等 | 管理后台「系统设置」(保存即生效) | - ---- - -## 接下来做什么 - -1. 逛一遍现有板块 -2. 发第一篇帖,或回一楼 -3. 需要时再进后台微调站点设置 - -聊起来就对了。 diff --git a/docs/site-posts/03-features.md b/docs/site-posts/03-features.md deleted file mode 100644 index 028a820..0000000 --- a/docs/site-posts/03-features.md +++ /dev/null @@ -1,89 +0,0 @@ -# 功能速览 - -> 建议标题:功能速览 -> 建议标签:`站务` `功能` -> 建议操作:普通发布 - ---- - -姜十三论坛刻意把功能收在「小圈子真正用得到」的范围内。 -下面按前台、互动、管理三块速览。 - ---- - -## 前台浏览 - -- 三栏布局,桌面与移动端自适应 -- Feed 排序:最新发帖 / 最新回复 / 热门 -- 虚拟滚动,长列表也更流畅 -- 浅色 / 暗色主题,偏好可记住 -- 板块图标与主题色可自定义 - ---- - -## 发帖与讨论 - -- TipTap 富文本编辑器 -- 标题、标签、板块选择 -- 正文图片上传 -- 楼层评论:回复、引用、@ -- 点赞、收藏 -- 帖子置顶、精华 -- 编辑修订历史(可对比差异) -- 可配置普通用户的编辑时限 - ---- - -## 社交与个人 - -- 公开用户主页 -- 站内私信(会话式) -- 账号资料:昵称、签名、头像 - ---- - -## 管理后台 - -统一在 `/admin`,与前台同一套 React 体验: - -| 模块 | 能做什么 | -| --- | --- | -| 仪表盘 | 概览站点状态 | -| 板块 | 创建 / 编辑 / 排序 / 外观 | -| 帖子 | 审核、置顶、精华、锁定、回收站 | -| 评论 | 审核与管理 | -| 举报 | 处理用户举报 | -| 用户 | 禁言等管理操作 | -| 媒体 | 查看与清理上传文件 | -| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 | -| 备份 | SQLite 一键备份 | - ---- - -## 可选能力 - -这些不是「装完就必须开」的,按需在后台启用: - -- 邮件验证码注册 -- OIDC Provider(可给 Gitea 等站点做登录源) -- Gitea 公开仓库同步到「开源码桶」 -- 本地磁盘或 S3 兼容对象存储 - ---- - -## 权限模型 - -只有两种角色: - -- **普通用户** -- **管理员** - -第一个注册用户自动成为管理员。 -小圈子场景够用,不堆复杂角色矩阵。 - ---- - -## 开发状态 - -项目仍在积极开发中,核心体验已经可用。 -完整规划见仓库中的 `ROADMAP.md`。欢迎反馈与共建。 diff --git a/docs/site-posts/04-deployment.md b/docs/site-posts/04-deployment.md deleted file mode 100644 index f41dac9..0000000 --- a/docs/site-posts/04-deployment.md +++ /dev/null @@ -1,68 +0,0 @@ -# 为什么是单文件部署 - -> 建议标题:为什么是单文件部署 -> 建议标签:`站务` `部署` `技术` -> 建议操作:按需发布(给关心自建的人看) - ---- - -姜十三论坛最硬核的卖点之一,是部署路径极短。 - -不是「理论上可以打包」,而是日常用法就是: - -1. 编译得到一个二进制 -2. 放到目录里运行 -3. 自动生成 `app.ini` -4. 浏览器注册,第一个账号即管理员 - ---- - -## 它解决什么问题 - -传统论坛方案常常落在两端: - -- **很重**:功能全,但部署与运维成本高 -- **很碎**:聊天工具 / 文档工具方便,却缺少「发帖—回复—沉淀」的社区节奏 - -小圈子真正需要的往往是中间态: - -- 有论坛结构,讨论可追溯 -- 界面清爽,用起来顺手 -- 部署简单到一个人能搞定 -- 数据清楚、备份好懂 - -单文件部署,就是为最后两点服务的。 - ---- - -## 具体意味着什么 - -| 能力 | 说明 | -| --- | --- | -| 单二进制 | 前端静态资源已内嵌,不必单独部署一套前端目录 | -| 内置 SQLite | 零外部数据库,数据落在本地目录 | -| 精简 `app.ini` | 主要管端口、数据目录、JWT 等进程级配置 | -| 后台热配置 | OIDC、邮件、存储、品牌等在管理后台修改,保存即生效 | -| 系统服务 | 内置 Linux systemd / Windows Service 安装与启停 | -| 跨平台 | Windows / Linux / macOS 均可编译与运行 | - -备份也直观:数据目录里的数据库、上传文件、配置,结构清晰,拷贝即可留存。 - ---- - -## 配置怎么分工 - -| 改什么 | 去哪里 | -| --- | --- | -| HTTP 端口、数据目录、JWT | `app.ini`(修改后重启进程 / 服务) | -| 站点品牌、OIDC、邮件、对象存储、限流、敏感词 | 管理后台 → 系统设置 | - -这样既保留「改文件就能控进程」的可控性,又避免把所有业务项都塞进配置文件。 - ---- - -## 一句话总结 - -**姜十三论坛** = 小圈子该有的论坛能力 × 清新好用的界面 × 真正能单文件带走的部署方式。 - -源码与说明见项目仓库。许可证:MIT。 diff --git a/docs/site-posts/README.md b/docs/site-posts/README.md index fe4ca77..3f7ed3d 100644 --- a/docs/site-posts/README.md +++ b/docs/site-posts/README.md @@ -1,7 +1,5 @@ # 站务公告 · 发帖素材 -本目录提供「站务公告」板块的建板参数,以及可直接复制到论坛的介绍文章(Markdown)。 - ## 板块设置 | 项 | 建议值 | @@ -9,21 +7,22 @@ | 名称 | 站务公告 | | 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 | | 图标 | `megaphone` | -| 色标 | 建议第 0 或 1 槽(稳重、好辨认) | -| 排序 | `0`(尽量靠前) | +| 色标 | 建议第 0 或 1 槽 | +| 排序 | `0` | -## 文章与标签 +## 主文(必发) -| 文件 | 建议标题 | 标签 | 操作 | +| 文件 | 标题 | 标签 | 操作 | | --- | --- | --- | --- | -| [01-welcome.md](./01-welcome.md) | 欢迎来到本站 | `站务` `介绍` | **置顶**(可选精华) | -| [02-getting-started.md](./02-getting-started.md) | 三分钟上手本站 | `站务` `指南` | 普通发布 | -| [03-features.md](./03-features.md) | 功能速览 | `站务` `功能` | 普通发布 | -| [04-deployment.md](./04-deployment.md) | 为什么是单文件部署 | `站务` `部署` `技术` | 按需发布 | +| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) | -## 发布建议 +把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。 -1. 先建板块,再按上表顺序发帖。 -2. 每篇复制「标题 / 标签 / 正文」三块;正文从 `---` 后的正文区开始粘贴。 -3. 若编辑器是富文本:粘贴后检查标题层级与列表是否正常。 -4. 本板块仅发站务与介绍;日常讨论放到其他板块。 +## 可选短文 + +若站务区希望不止一篇,见 [optional.md](./optional.md): + +- [community-guidelines.md](./community-guidelines.md) — 发帖约定 +- [feedback.md](./feedback.md) — 反馈与建议 + +复制正文时:从各文件第一个 `---` **之后**开始粘贴。 diff --git a/docs/site-posts/about-jiang13.md b/docs/site-posts/about-jiang13.md new file mode 100644 index 0000000..170cab6 --- /dev/null +++ b/docs/site-posts/about-jiang13.md @@ -0,0 +1,185 @@ +# 关于本站 · 姜十三论坛 + +> 建议标题:关于本站 · 姜十三论坛 +> 建议标签:`站务` `介绍` +> 建议操作:置顶(可选精华) + +--- + +你好,欢迎加入本站。 + +这里运行的是开源论坛 **姜十三论坛(Jiang13 Forum)**——面向小圈子、团队与同好社群,目标很明确: + +**能聊 · 好看 · 好装** + +下文介绍它是什么、怎么上手、有哪些能力,以及为何采用单文件部署。读完即可开始发帖;细节可随时回来查阅。 + +--- + +## 一、它是什么 + +姜十三论坛不是大而全的社区平台,而是一款轻量、现代化的论坛软件。 + +它做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。 + +- 有板块、帖子、楼层回复,讨论能沉淀、能追溯 +- 界面清爽,信息密度适中,桌面与手机都好用 +- 单个程序即可部署,备份路径清晰 + +技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。拷到服务器、启动、注册——就可以用。 + +### 谁适合用 + +| 场景 | 说明 | +| --- | --- | +| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 | +| 兴趣小圈子 | 同好交流、作品分享、活动组织 | +| 项目配套社区 | 可与 Gitea 等做 SSO 联动 | +| 个人站长 | 单机可跑,无需云数据库与一堆微服务 | + +若你需要百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。 +若你要的是 **小而美、自己能装、自己能管**,就对了。 + +### 本板块说明 + +「站务公告」只发站务与介绍类内容。日常聊天、提问、分享,请到其他讨论板块。 + +--- + +## 二、三分钟上手 + +### 注册与登录 + +1. 打开注册页,填写用户名、邮箱与密码。 +2. 若已开启邮件服务,可能需要邮箱验证码。 +3. **第一个注册用户自动成为管理员**,无需安装向导。 + +登录后,右上角会出现头像与私信入口。 + +### 认识首页 + +| 区域 | 内容 | +| --- | --- | +| 左侧 | 板块导航、常用入口 | +| 中间 | 帖子列表(Feed) | +| 右侧 | 热门帖、最新评论等 | + +列表可按 **最新发帖 / 最新回复 / 热门** 切换。 +支持浅色 / 暗色主题,偏好会记住。手机上侧栏会收起,不影响浏览与发帖。 + +### 发帖与回复 + +点「发帖」→ 选板块 → 写标题、标签(可选)与正文。 +正文支持富文本与图片上传。评论是 **楼层式** 的,可回复、引用、@。 + +帖子可点赞、收藏;公开主页可展示你的资料与动态。 + +### 个人入口 + +头像菜单常见项:个人主页、账号设置、站内私信、我的收藏。 + +### 管理后台(仅管理员) + +访问 `/admin`:板块、帖子、评论、举报、用户、媒体、系统设置,以及备份等。 + +| 改什么 | 去哪里 | +| --- | --- | +| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) | +| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) | + +逛完板块,发第一帖或回一楼——聊起来就对了。 + +--- + +## 三、功能速览 + +功能刻意收在「小圈子真正用得到」的范围内。 + +### 浏览与发帖 + +- 三栏布局,桌面 / 移动自适应 +- Feed 排序与虚拟滚动 +- 浅色 / 暗色主题;板块图标与主题色可配 +- TipTap 富文本、标签、正文图片 +- 楼层评论:回复、引用、@ +- 点赞、收藏;置顶 / 精华 +- 编辑修订历史(可对比差异) +- 可配置普通用户的编辑时限 + +### 社交与个人 + +- 公开用户主页 +- 会话式站内私信 +- 昵称、签名、头像等资料 + +### 管理后台 + +与前台同一套 React 体验,统一在 `/admin`: + +| 模块 | 能力 | +| --- | --- | +| 仪表盘 | 站点概览 | +| 板块 | 创建、编辑、排序、外观 | +| 帖子 | 审核、置顶、精华、锁定、回收站 | +| 评论 / 举报 | 审核与处理 | +| 用户 | 禁言等 | +| 媒体 | 查看与清理上传 | +| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 | +| 备份 | SQLite 一键备份 | + +### 可选能力 + +按需在后台开启,不是装完就必须用: + +- 邮件验证码注册 +- OIDC Provider(可作 Gitea 等登录源) +- Gitea 公开仓库同步到「开源码桶」 +- 本地磁盘或 S3 兼容对象存储 + +### 权限 + +只有两种角色:**普通用户** / **管理员**。 +第一个注册用户即管理员。小圈子够用,不堆角色矩阵。 + +项目仍在积极开发。完整规划见仓库 `ROADMAP.md`,欢迎反馈与共建。 + +--- + +## 四、为什么是单文件部署 + +最硬核的一点,是部署路径极短——不是「理论上能打包」,而是日常用法就是: + +1. 编译得到一个二进制 +2. 放到目录里运行 +3. 自动生成 `app.ini` +4. 浏览器注册,第一个账号即管理员 + +### 它补哪块空缺 + +论坛方案常落在两端:很重(功能全、运维贵),或很碎(聊天 / 文档方便,却缺少发帖沉淀)。 +小圈子往往需要中间态:有论坛结构、界面顺手、一个人能装、备份好懂。单文件部署,就是为后两点服务的。 + +### 具体意味着什么 + +| 能力 | 说明 | +| --- | --- | +| 单二进制 | 前端已内嵌,不必单独部署前端目录 | +| 内置 SQLite | 零外部数据库,数据在本地目录 | +| 精简 `app.ini` | 主要管端口、数据目录、JWT | +| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 | +| 系统服务 | 内置 Linux systemd / Windows Service | +| 跨平台 | Windows / Linux / macOS 均可编译运行 | + +备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。 + +进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。 + +--- + +## 写在最后 + +**姜十三论坛** = 小圈子该有的论坛能力 × 清新界面 × 真正能单文件带走的部署。 + +源码与说明见项目仓库。许可证:MIT。 + +有问题可联系管理员。祝你在这里聊得愉快。 diff --git a/docs/site-posts/community-guidelines.md b/docs/site-posts/community-guidelines.md new file mode 100644 index 0000000..28dd27c --- /dev/null +++ b/docs/site-posts/community-guidelines.md @@ -0,0 +1,30 @@ +# 本站发帖约定 + +> 建议标题:本站发帖约定 +> 建议标签:`站务` `公约` +> 建议操作:普通发布(可选) + +--- + +本站是小圈子论坛,约定尽量少、好遵守。 + +## 请这样做 + +- 选对板块再发;不确定就发到综合讨论类板块 +- 标题写清楚在说什么,方便后人搜索 +- 讨论对事不对人;引用、回复尽量指向具体楼层 +- 分享链接时简单说明来源与用途 + +## 请避免 + +- 在「站务公告」灌水、闲聊(请到其他板块) +- 无意义刷屏、重复灌帖 +- 人身攻击、骚扰、泄露他人隐私 +- 明显的广告、引流(除非板块明确允许) + +## 管理说明 + +管理员可能会:移动板块、编辑标题、锁定、删除或禁言。 +有争议可私信管理员说明情况。 + +一句话:**把讨论留给值得留下的内容。** diff --git a/docs/site-posts/feedback.md b/docs/site-posts/feedback.md new file mode 100644 index 0000000..231769d --- /dev/null +++ b/docs/site-posts/feedback.md @@ -0,0 +1,26 @@ +# 反馈与建议 + +> 建议标题:反馈与建议 +> 建议标签:`站务` `反馈` +> 建议操作:普通发布(可选) + +--- + +本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。 + +## 站内怎么说 + +- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望 +- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好 +- **站务问题**(注册、权限、内容处理):私信管理员 + +## 开发仓库(可选) + +若你也参与程序本身: + +- 查路线图、提 Issue / PR:见项目仓库 +- 许可证:MIT + +站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。 + +感谢一起把小圈子聊好。 diff --git a/docs/site-posts/optional.md b/docs/site-posts/optional.md new file mode 100644 index 0000000..78ac0f1 --- /dev/null +++ b/docs/site-posts/optional.md @@ -0,0 +1,10 @@ +# 可选短文(不与介绍长文重复) + +长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。 + +若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。 + +| 文件 | 标题 | 标签 | 说明 | +| --- | --- | --- | --- | +| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 | +| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 | diff --git a/embed_static/embed.go b/embed_static/embed.go index 5a0501c..aad0da8 100644 --- a/embed_static/embed.go +++ b/embed_static/embed.go @@ -32,7 +32,12 @@ func SetSPABrandingJSON(fn func() []byte) { // SetupEmbed 配置内嵌资源:React SPA 静态资源 func SetupEmbed(r *gin.Engine) error { if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil { - r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub))))) + 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) + }) } return nil } diff --git a/embed_static/spa_meta.go b/embed_static/spa_meta.go index c534ac9..a682aa3 100644 --- a/embed_static/spa_meta.go +++ b/embed_static/spa_meta.go @@ -37,6 +37,8 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) { return } data = applySPAPageMeta(data, meta) + // 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希 + c.Header("Cache-Control", "no-cache") c.Data(status, "text/html; charset=utf-8", data) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1f4904..48dfd68 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { Suspense } from 'react'; import { createBrowserRouter, createRoutesFromElements, @@ -12,33 +12,36 @@ 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 = lazy(() => import('./pages/HomePage')); -const PostDetailPage = lazy(() => import('./pages/PostDetailPage')); -const LoginPage = lazy(() => import('./pages/LoginPage')); -const RegisterPage = lazy(() => import('./pages/RegisterPage')); -const ComposePage = lazy(() => import('./pages/ComposePage')); -const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage')); -const ProfilePage = lazy(() => import('./pages/ProfilePage')); -const UserProfilePage = lazy(() => import('./pages/UserProfilePage')); -const FavoritesPage = lazy(() => import('./pages/FavoritesPage')); -const MessagesPage = lazy(() => import('./pages/MessagesPage')); -const ProjectsPage = lazy(() => import('./pages/ProjectsPage')); -const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage')); -const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage')); -const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage')); -const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage')); -const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage')); -const AdminMediaPage = lazy(() => import('./pages/admin/AdminMediaPage')); -const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage')); -const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); +const HomePage = lazyWithRetry(() => import('./pages/HomePage')); +const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage')); +const LoginPage = lazyWithRetry(() => import('./pages/LoginPage')); +const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage')); +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 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 AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); +const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); +const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); const router = createBrowserRouter( createRoutesFromElements( - <> + }> }>} /> }>} /> } /> @@ -68,7 +71,7 @@ const router = createBrowserRouter( }>} /> }>} /> - , + , ), ); @@ -78,6 +81,7 @@ export default function App() { + diff --git a/frontend/src/components/AppRouteError.tsx b/frontend/src/components/AppRouteError.tsx new file mode 100644 index 0000000..63f13f8 --- /dev/null +++ b/frontend/src/components/AppRouteError.tsx @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad'; + +function errorMessage(error: unknown): string { + if (isRouteErrorResponse(error)) { + if (typeof error.data === 'string' && error.data) return error.data; + if (error.data && typeof error.data === 'object' && 'message' in error.data) { + const m = (error.data as { message?: unknown }).message; + if (typeof m === 'string' && m) return m; + } + return error.statusText || `错误 ${error.status}`; + } + if (error instanceof Error) return error.message; + return String(error ?? '未知错误'); +} + +/** + * React Router 路由级错误页。 + * 发版后动态模块 404 时自动刷新;其它错误给出手动刷新入口。 + */ +export default function AppRouteError() { + const error = useRouteError(); + const chunkMiss = isChunkLoadError(error); + + useEffect(() => { + if (chunkMiss) reloadForStaleChunk(); + }, [chunkMiss]); + + if (chunkMiss) { + return ( +
+
+
+

正在更新页面

+

检测到程序已更新,正在自动刷新以加载最新版本…

+
+
+ ); + } + + return ( +
+
+
500
+

页面加载出错

+

{errorMessage(error)}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 593391b..34257e9 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -2,7 +2,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode, } from 'react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; -import { TextSelection } from '@tiptap/pm/state'; +import { TextSelection, NodeSelection } from '@tiptap/pm/state'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; @@ -73,6 +73,35 @@ function isEditorEmpty(editor: Editor): boolean { return editor.state.doc.textContent.trim().length === 0; } +/** + * 将光标放到第一个文本块开头。 + * 文档以分割线等原子节点开头时,默认选区会变成 NodeSelection,出现整条高亮。 + */ +function placeCaretInFirstTextblock(editor: Editor) { + const { state } = editor; + const { doc, selection } = state; + let pos: number | null = null; + doc.descendants((node, nodePos) => { + if (node.isTextblock) { + pos = nodePos + 1; + return false; + } + return true; + }); + if (pos == null) return; + + const needsMove = + selection instanceof NodeSelection + || selection.from !== pos + || selection.to !== pos; + if (!needsMove) return; + + const tr = state.tr + .setSelection(TextSelection.create(doc, pos)) + .setMeta('addToHistory', false); + editor.view.dispatch(tr); +} + /** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */ function cycleHeading(editor: Editor) { for (let level = 2; level <= 6; level += 1) { @@ -181,6 +210,10 @@ const ArticleEditor = forwardRef(function ArticleEdi TabIndent, ], content: sanitizeHtml(value) || '', + autofocus: false, + onCreate: ({ editor: ed }) => { + placeCaretInFirstTextblock(ed); + }, onUpdate: ({ editor: ed }) => { const html = sanitizeHtml(ed.getHTML()); isInternalUpdate.current = true; @@ -229,6 +262,8 @@ const ArticleEditor = forwardRef(function ArticleEdi if (next === lastValueRef.current) return; lastValueRef.current = next; editor.commands.setContent(next || '', { emitUpdate: false }); + // 加载长文时避免首行分割线被 NodeSelection 选中 + placeCaretInFirstTextblock(editor); }, [value, editor, mode]); // 全屏时锁定页面滚动,Esc 退出 @@ -267,7 +302,9 @@ const ArticleEditor = forwardRef(function ArticleEdi markdownRef.current?.focus(); return; } - editor?.commands.focus(); + if (!editor) return; + placeCaretInFirstTextblock(editor); + editor.commands.focus(); }, }), [editor, value, mode, markdownSource]); @@ -365,6 +402,7 @@ const ArticleEditor = forwardRef(function ArticleEdi onChange(html); if (editor) { editor.commands.setContent(html || '', { emitUpdate: false }); + placeCaretInFirstTextblock(editor); } setMode('rich'); }, [editor, markdownSource, onChange]); diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index cec9281..408d20f 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,15 +1,19 @@ import { Component, ErrorInfo, ReactNode } from 'react'; import { Button } from '@/components/ui/button'; +import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad'; interface Props { children: ReactNode } -interface State { error: Error | null } +interface State { error: Error | null; reloading: boolean } -/** 捕获渲染异常,避免整页白屏 */ +/** 捕获渲染异常;发版 chunk 失效时自动刷新 */ export default class ErrorBoundary extends Component { - state: State = { error: null }; + state: State = { error: null, reloading: false }; static getDerivedStateFromError(error: Error) { - return { error }; + if (isChunkLoadError(error) && reloadForStaleChunk()) { + return { error, reloading: true }; + } + return { error, reloading: false }; } componentDidCatch(error: Error, info: ErrorInfo) { @@ -17,6 +21,18 @@ export default class ErrorBoundary extends Component { } render() { + if (this.state.reloading) { + return ( +
+
+
+

正在更新页面

+

检测到程序已更新,正在自动刷新以加载最新版本…

+
+
+ ); + } + if (this.state.error) { return (
diff --git a/frontend/src/components/PullToRefresh.tsx b/frontend/src/components/PullToRefresh.tsx new file mode 100644 index 0000000..9076233 --- /dev/null +++ b/frontend/src/components/PullToRefresh.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2, ArrowDown } from 'lucide-react'; + +/** 触发刷新的下拉距离(px) */ +const REFRESH_THRESHOLD = 68; +/** 指示器最大位移 */ +const PULL_MAX = 108; +/** 判定为「下拉」意图的最小位移,避免误触滚动 */ +const ARM_DELTA = 10; + +/** + * 定位当前真正滚动的容器。 + * SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。 + */ +function pickScrollEl(): HTMLElement | null { + const list = document.querySelector('.post-list-scroll'); + if (list) return list; + + const page = document.querySelector('.page-wrap:not(.page-wrap--feed)'); + if (page) return page; + + const compose = document.querySelector('.main-content--compose'); + if (compose) return compose; + + const admin = document.querySelector('.admin-main'); + if (admin) return admin; + + const auth = document.querySelector('.auth-page'); + if (auth) return auth; + + return null; +} + +function isTouchDevice(): boolean { + return window.matchMedia('(hover: none) and (pointer: coarse)').matches + || navigator.maxTouchPoints > 0; +} + +/** + * 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。 + * (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。) + * + * 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。 + */ +export default function PullToRefresh() { + const [pull, setPull] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const pullRef = useRef(0); + const refreshingRef = useRef(false); + const startYRef = useRef(0); + const trackingRef = useRef(false); + const pullingRef = useRef(false); + const scrollElRef = useRef(null); + + useEffect(() => { + pullRef.current = pull; + }, [pull]); + + useEffect(() => { + refreshingRef.current = refreshing; + }, [refreshing]); + + useEffect(() => { + if (!isTouchDevice()) return; + + let cancelled = false; + let debounceTimer: ReturnType | undefined; + let bound: HTMLElement | null = null; + + const resetGesture = () => { + trackingRef.current = false; + pullingRef.current = false; + startYRef.current = 0; + if (!refreshingRef.current) setPull(0); + }; + + const onTouchStart = (e: TouchEvent) => { + if (refreshingRef.current || e.touches.length !== 1) return; + const el = scrollElRef.current; + if (!el || el.scrollTop > 1) return; + if (document.querySelector( + '.sidebar-drawer-root, .aside-drawer-root, .image-lightbox, [aria-modal="true"]', + )) { + return; + } + trackingRef.current = true; + pullingRef.current = false; + startYRef.current = e.touches[0].clientY; + }; + + const onTouchMove = (e: TouchEvent) => { + if (!trackingRef.current || refreshingRef.current || e.touches.length !== 1) return; + const el = scrollElRef.current; + if (!el) return; + + if (el.scrollTop > 1) { + resetGesture(); + return; + } + + const dy = e.touches[0].clientY - startYRef.current; + if (dy < ARM_DELTA) { + if (pullingRef.current && dy <= 0) resetGesture(); + return; + } + + pullingRef.current = true; + setPull(Math.min(PULL_MAX, dy * 0.55)); + if (e.cancelable) e.preventDefault(); + }; + + const onTouchEnd = () => { + if (!trackingRef.current) return; + const shouldRefresh = pullingRef.current && pullRef.current >= REFRESH_THRESHOLD; + trackingRef.current = false; + pullingRef.current = false; + + if (shouldRefresh) { + setRefreshing(true); + setPull(REFRESH_THRESHOLD * 0.7); + window.setTimeout(() => { + window.location.reload(); + }, 180); + return; + } + setPull(0); + }; + + const unbind = () => { + if (!bound) return; + bound.removeEventListener('touchstart', onTouchStart); + bound.removeEventListener('touchmove', onTouchMove); + bound.removeEventListener('touchend', onTouchEnd); + bound.removeEventListener('touchcancel', onTouchEnd); + bound = null; + }; + + const bind = (el: HTMLElement) => { + if (bound === el) return; + unbind(); + bound = el; + scrollElRef.current = el; + el.addEventListener('touchstart', onTouchStart, { passive: true }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd, { passive: true }); + el.addEventListener('touchcancel', onTouchEnd, { passive: true }); + }; + + const tryBind = () => { + if (cancelled) return; + const next = pickScrollEl(); + if (next) bind(next); + }; + + const scheduleBind = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(tryBind, 40); + }; + + tryBind(); + + const root = document.getElementById('root') ?? document.body; + const mo = new MutationObserver(scheduleBind); + mo.observe(root, { childList: true, subtree: true }); + window.addEventListener('popstate', scheduleBind); + + return () => { + cancelled = true; + clearTimeout(debounceTimer); + mo.disconnect(); + window.removeEventListener('popstate', scheduleBind); + unbind(); + scrollElRef.current = null; + trackingRef.current = false; + pullingRef.current = false; + }; + }, []); + + if (!refreshing && pull <= 0) return null; + + const ready = pull >= REFRESH_THRESHOLD || refreshing; + const offset = Math.max(pull, refreshing ? 48 : 0); + + return ( +
+
+ {refreshing ? ( + + ) : ( + + )} + {refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'} +
+
+ ); +} diff --git a/frontend/src/pages/ComposePage.tsx b/frontend/src/pages/ComposePage.tsx index ba3de12..3c66c9d 100644 --- a/frontend/src/pages/ComposePage.tsx +++ b/frontend/src/pages/ComposePage.tsx @@ -303,6 +303,7 @@ export default function ComposePage() {
+
板块 @@ -347,6 +348,7 @@ export default function ComposePage() { placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。" />
+
; + +/** + * 带发版容错的 React.lazy:动态 import 失败时自动整页刷新一次。 + * (部署后 hashed chunk 更名,旧标签页点到懒加载路由时常见) + */ +export function lazyWithRetry( + factory: () => Promise<{ default: T }>, +): LazyExoticComponent { + return lazy(async () => { + try { + return await factory(); + } catch (err) { + if (isChunkLoadError(err) && reloadForStaleChunk()) { + // 刷新进行中,挂起 Promise,避免再抛到错误页闪一下 + return new Promise<{ default: T }>(() => {}); + } + throw err; + } + }); +}