修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,55 +0,0 @@
|
|||||||
# 欢迎来到本站
|
|
||||||
|
|
||||||
> 建议标题:欢迎来到本站
|
|
||||||
> 建议标签:`站务` `介绍`
|
|
||||||
> 建议操作:置顶(可选精华)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
你好,欢迎加入本站。
|
|
||||||
|
|
||||||
这里运行的是开源论坛程序 **姜十三论坛(Jiang13 Forum)**。
|
|
||||||
它面向小圈子、团队与同好社群,目标很简单:
|
|
||||||
|
|
||||||
**能聊 · 好看 · 好装**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 这是什么
|
|
||||||
|
|
||||||
姜十三论坛不是「大而全」的社区平台,而是一个轻量、现代化的论坛:
|
|
||||||
|
|
||||||
- 有板块、帖子、楼层回复,讨论能沉淀、能追溯
|
|
||||||
- 界面清爽,信息密度适中,桌面与手机都好用
|
|
||||||
- 单个程序即可部署,数据在自己手里
|
|
||||||
|
|
||||||
技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。
|
|
||||||
拷到服务器、启动、注册——就可以开始用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 谁适合用
|
|
||||||
|
|
||||||
| 场景 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
|
||||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
|
||||||
| 项目配套社区 | 可与 Gitea 等工具做 SSO 联动 |
|
|
||||||
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
|
|
||||||
|
|
||||||
如果你需要的是百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。
|
|
||||||
如果你要的是 **小而美、自己能装、自己能管**,就对了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 本板块说明
|
|
||||||
|
|
||||||
「站务公告」只发布:
|
|
||||||
|
|
||||||
- 本站介绍与使用指引
|
|
||||||
- 规则与站务通知
|
|
||||||
- 与姜十三论坛相关的说明
|
|
||||||
|
|
||||||
日常聊天、提问、分享,请到其他讨论板块。
|
|
||||||
|
|
||||||
有问题可联系管理员。祝你在这里聊得愉快。
|
|
||||||
@@ -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. 需要时再进后台微调站点设置
|
|
||||||
|
|
||||||
聊起来就对了。
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# 功能速览
|
|
||||||
|
|
||||||
> 建议标题:功能速览
|
|
||||||
> 建议标签:`站务` `功能`
|
|
||||||
> 建议操作:普通发布
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
姜十三论坛刻意把功能收在「小圈子真正用得到」的范围内。
|
|
||||||
下面按前台、互动、管理三块速览。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前台浏览
|
|
||||||
|
|
||||||
- 三栏布局,桌面与移动端自适应
|
|
||||||
- Feed 排序:最新发帖 / 最新回复 / 热门
|
|
||||||
- 虚拟滚动,长列表也更流畅
|
|
||||||
- 浅色 / 暗色主题,偏好可记住
|
|
||||||
- 板块图标与主题色可自定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 发帖与讨论
|
|
||||||
|
|
||||||
- TipTap 富文本编辑器
|
|
||||||
- 标题、标签、板块选择
|
|
||||||
- 正文图片上传
|
|
||||||
- 楼层评论:回复、引用、@
|
|
||||||
- 点赞、收藏
|
|
||||||
- 帖子置顶、精华
|
|
||||||
- 编辑修订历史(可对比差异)
|
|
||||||
- 可配置普通用户的编辑时限
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 社交与个人
|
|
||||||
|
|
||||||
- 公开用户主页
|
|
||||||
- 站内私信(会话式)
|
|
||||||
- 账号资料:昵称、签名、头像
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 管理后台
|
|
||||||
|
|
||||||
统一在 `/admin`,与前台同一套 React 体验:
|
|
||||||
|
|
||||||
| 模块 | 能做什么 |
|
|
||||||
| --- | --- |
|
|
||||||
| 仪表盘 | 概览站点状态 |
|
|
||||||
| 板块 | 创建 / 编辑 / 排序 / 外观 |
|
|
||||||
| 帖子 | 审核、置顶、精华、锁定、回收站 |
|
|
||||||
| 评论 | 审核与管理 |
|
|
||||||
| 举报 | 处理用户举报 |
|
|
||||||
| 用户 | 禁言等管理操作 |
|
|
||||||
| 媒体 | 查看与清理上传文件 |
|
|
||||||
| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 |
|
|
||||||
| 备份 | SQLite 一键备份 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 可选能力
|
|
||||||
|
|
||||||
这些不是「装完就必须开」的,按需在后台启用:
|
|
||||||
|
|
||||||
- 邮件验证码注册
|
|
||||||
- OIDC Provider(可给 Gitea 等站点做登录源)
|
|
||||||
- Gitea 公开仓库同步到「开源码桶」
|
|
||||||
- 本地磁盘或 S3 兼容对象存储
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 权限模型
|
|
||||||
|
|
||||||
只有两种角色:
|
|
||||||
|
|
||||||
- **普通用户**
|
|
||||||
- **管理员**
|
|
||||||
|
|
||||||
第一个注册用户自动成为管理员。
|
|
||||||
小圈子场景够用,不堆复杂角色矩阵。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发状态
|
|
||||||
|
|
||||||
项目仍在积极开发中,核心体验已经可用。
|
|
||||||
完整规划见仓库中的 `ROADMAP.md`。欢迎反馈与共建。
|
|
||||||
@@ -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。
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# 站务公告 · 发帖素材
|
# 站务公告 · 发帖素材
|
||||||
|
|
||||||
本目录提供「站务公告」板块的建板参数,以及可直接复制到论坛的介绍文章(Markdown)。
|
|
||||||
|
|
||||||
## 板块设置
|
## 板块设置
|
||||||
|
|
||||||
| 项 | 建议值 |
|
| 项 | 建议值 |
|
||||||
@@ -9,21 +7,22 @@
|
|||||||
| 名称 | 站务公告 |
|
| 名称 | 站务公告 |
|
||||||
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
|
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
|
||||||
| 图标 | `megaphone` |
|
| 图标 | `megaphone` |
|
||||||
| 色标 | 建议第 0 或 1 槽(稳重、好辨认) |
|
| 色标 | 建议第 0 或 1 槽 |
|
||||||
| 排序 | `0`(尽量靠前) |
|
| 排序 | `0` |
|
||||||
|
|
||||||
## 文章与标签
|
## 主文(必发)
|
||||||
|
|
||||||
| 文件 | 建议标题 | 标签 | 操作 |
|
| 文件 | 标题 | 标签 | 操作 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| [01-welcome.md](./01-welcome.md) | 欢迎来到本站 | `站务` `介绍` | **置顶**(可选精华) |
|
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
|
||||||
| [02-getting-started.md](./02-getting-started.md) | 三分钟上手本站 | `站务` `指南` | 普通发布 |
|
|
||||||
| [03-features.md](./03-features.md) | 功能速览 | `站务` `功能` | 普通发布 |
|
|
||||||
| [04-deployment.md](./04-deployment.md) | 为什么是单文件部署 | `站务` `部署` `技术` | 按需发布 |
|
|
||||||
|
|
||||||
## 发布建议
|
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||||
|
|
||||||
1. 先建板块,再按上表顺序发帖。
|
## 可选短文
|
||||||
2. 每篇复制「标题 / 标签 / 正文」三块;正文从 `---` 后的正文区开始粘贴。
|
|
||||||
3. 若编辑器是富文本:粘贴后检查标题层级与列表是否正常。
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
4. 本板块仅发站务与介绍;日常讨论放到其他板块。
|
|
||||||
|
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
|
||||||
|
- [feedback.md](./feedback.md) — 反馈与建议
|
||||||
|
|
||||||
|
复制正文时:从各文件第一个 `---` **之后**开始粘贴。
|
||||||
|
|||||||
185
docs/site-posts/about-jiang13.md
Normal file
185
docs/site-posts/about-jiang13.md
Normal file
@@ -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。
|
||||||
|
|
||||||
|
有问题可联系管理员。祝你在这里聊得愉快。
|
||||||
30
docs/site-posts/community-guidelines.md
Normal file
30
docs/site-posts/community-guidelines.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 本站发帖约定
|
||||||
|
|
||||||
|
> 建议标题:本站发帖约定
|
||||||
|
> 建议标签:`站务` `公约`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站是小圈子论坛,约定尽量少、好遵守。
|
||||||
|
|
||||||
|
## 请这样做
|
||||||
|
|
||||||
|
- 选对板块再发;不确定就发到综合讨论类板块
|
||||||
|
- 标题写清楚在说什么,方便后人搜索
|
||||||
|
- 讨论对事不对人;引用、回复尽量指向具体楼层
|
||||||
|
- 分享链接时简单说明来源与用途
|
||||||
|
|
||||||
|
## 请避免
|
||||||
|
|
||||||
|
- 在「站务公告」灌水、闲聊(请到其他板块)
|
||||||
|
- 无意义刷屏、重复灌帖
|
||||||
|
- 人身攻击、骚扰、泄露他人隐私
|
||||||
|
- 明显的广告、引流(除非板块明确允许)
|
||||||
|
|
||||||
|
## 管理说明
|
||||||
|
|
||||||
|
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
|
||||||
|
有争议可私信管理员说明情况。
|
||||||
|
|
||||||
|
一句话:**把讨论留给值得留下的内容。**
|
||||||
26
docs/site-posts/feedback.md
Normal file
26
docs/site-posts/feedback.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 反馈与建议
|
||||||
|
|
||||||
|
> 建议标题:反馈与建议
|
||||||
|
> 建议标签:`站务` `反馈`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
|
||||||
|
|
||||||
|
## 站内怎么说
|
||||||
|
|
||||||
|
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
|
||||||
|
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
|
||||||
|
- **站务问题**(注册、权限、内容处理):私信管理员
|
||||||
|
|
||||||
|
## 开发仓库(可选)
|
||||||
|
|
||||||
|
若你也参与程序本身:
|
||||||
|
|
||||||
|
- 查路线图、提 Issue / PR:见项目仓库
|
||||||
|
- 许可证:MIT
|
||||||
|
|
||||||
|
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
|
||||||
|
|
||||||
|
感谢一起把小圈子聊好。
|
||||||
10
docs/site-posts/optional.md
Normal file
10
docs/site-posts/optional.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 可选短文(不与介绍长文重复)
|
||||||
|
|
||||||
|
长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。
|
||||||
|
|
||||||
|
若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 |
|
||||||
|
| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |
|
||||||
@@ -32,7 +32,12 @@ func SetSPABrandingJSON(fn func() []byte) {
|
|||||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||||
func SetupEmbed(r *gin.Engine) error {
|
func SetupEmbed(r *gin.Engine) error {
|
||||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data = applySPAPageMeta(data, meta)
|
data = applySPAPageMeta(data, meta)
|
||||||
|
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Data(status, "text/html; charset=utf-8", data)
|
c.Data(status, "text/html; charset=utf-8", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
createRoutesFromElements,
|
createRoutesFromElements,
|
||||||
@@ -12,33 +12,36 @@ import { ThemeProvider } from './hooks/useTheme';
|
|||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import AdminLayout from './layouts/AdminLayout';
|
import AdminLayout from './layouts/AdminLayout';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
import AppRouteError from './components/AppRouteError';
|
||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
import AuthPageFallback from './components/AuthPageFallback';
|
import AuthPageFallback from './components/AuthPageFallback';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
|
import PullToRefresh from './components/PullToRefresh';
|
||||||
|
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||||
|
|
||||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||||
const PostDetailPage = lazy(() => import('./pages/PostDetailPage'));
|
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||||
const ComposePage = lazy(() => import('./pages/ComposePage'));
|
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
|
||||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
|
||||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
|
||||||
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
|
const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
|
||||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
|
||||||
const MessagesPage = lazy(() => import('./pages/MessagesPage'));
|
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
|
||||||
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
|
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
|
||||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
|
||||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
|
||||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
|
||||||
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));
|
const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
|
||||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
|
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
||||||
const AdminMediaPage = lazy(() => import('./pages/admin/AdminMediaPage'));
|
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||||
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
<>
|
<Route errorElement={<AppRouteError />}>
|
||||||
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
||||||
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
||||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||||
@@ -68,7 +71,7 @@ const router = createBrowserRouter(
|
|||||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||||
</>,
|
</Route>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ export default function App() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<PullToRefresh />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
60
frontend/src/components/AppRouteError.tsx
Normal file
60
frontend/src/components/AppRouteError.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="error-page-shell">
|
||||||
|
<div className="error-page">
|
||||||
|
<div className="error-page__code" aria-hidden>…</div>
|
||||||
|
<h1 className="error-page__title">正在更新页面</h1>
|
||||||
|
<p className="error-page__desc">检测到程序已更新,正在自动刷新以加载最新版本…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-page-shell">
|
||||||
|
<div className="error-page">
|
||||||
|
<div className="error-page__code" aria-hidden>500</div>
|
||||||
|
<h1 className="error-page__title">页面加载出错</h1>
|
||||||
|
<p className="error-page__desc">{errorMessage(error)}</p>
|
||||||
|
<div className="error-page__actions">
|
||||||
|
<Button size="sm" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useEditor, EditorContent, type Editor } from '@tiptap/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 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';
|
||||||
@@ -73,6 +73,35 @@ function isEditorEmpty(editor: Editor): boolean {
|
|||||||
return editor.state.doc.textContent.trim().length === 0;
|
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 → 正文 */
|
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||||||
function cycleHeading(editor: Editor) {
|
function cycleHeading(editor: Editor) {
|
||||||
for (let level = 2; level <= 6; level += 1) {
|
for (let level = 2; level <= 6; level += 1) {
|
||||||
@@ -181,6 +210,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
TabIndent,
|
TabIndent,
|
||||||
],
|
],
|
||||||
content: sanitizeHtml(value) || '',
|
content: sanitizeHtml(value) || '',
|
||||||
|
autofocus: false,
|
||||||
|
onCreate: ({ editor: ed }) => {
|
||||||
|
placeCaretInFirstTextblock(ed);
|
||||||
|
},
|
||||||
onUpdate: ({ editor: ed }) => {
|
onUpdate: ({ editor: ed }) => {
|
||||||
const html = sanitizeHtml(ed.getHTML());
|
const html = sanitizeHtml(ed.getHTML());
|
||||||
isInternalUpdate.current = true;
|
isInternalUpdate.current = true;
|
||||||
@@ -229,6 +262,8 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
if (next === lastValueRef.current) return;
|
if (next === lastValueRef.current) return;
|
||||||
lastValueRef.current = next;
|
lastValueRef.current = next;
|
||||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||||
|
// 加载长文时避免首行分割线被 NodeSelection 选中
|
||||||
|
placeCaretInFirstTextblock(editor);
|
||||||
}, [value, editor, mode]);
|
}, [value, editor, mode]);
|
||||||
|
|
||||||
// 全屏时锁定页面滚动,Esc 退出
|
// 全屏时锁定页面滚动,Esc 退出
|
||||||
@@ -267,7 +302,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
markdownRef.current?.focus();
|
markdownRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editor?.commands.focus();
|
if (!editor) return;
|
||||||
|
placeCaretInFirstTextblock(editor);
|
||||||
|
editor.commands.focus();
|
||||||
},
|
},
|
||||||
}), [editor, value, mode, markdownSource]);
|
}), [editor, value, mode, markdownSource]);
|
||||||
|
|
||||||
@@ -365,6 +402,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
onChange(html);
|
onChange(html);
|
||||||
if (editor) {
|
if (editor) {
|
||||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||||
|
placeCaretInFirstTextblock(editor);
|
||||||
}
|
}
|
||||||
setMode('rich');
|
setMode('rich');
|
||||||
}, [editor, markdownSource, onChange]);
|
}, [editor, markdownSource, onChange]);
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
|
||||||
|
|
||||||
interface Props { children: ReactNode }
|
interface Props { children: ReactNode }
|
||||||
interface State { error: Error | null }
|
interface State { error: Error | null; reloading: boolean }
|
||||||
|
|
||||||
/** 捕获渲染异常,避免整页白屏 */
|
/** 捕获渲染异常;发版 chunk 失效时自动刷新 */
|
||||||
export default class ErrorBoundary extends Component<Props, State> {
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
state: State = { error: null };
|
state: State = { error: null, reloading: false };
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return { error };
|
if (isChunkLoadError(error) && reloadForStaleChunk()) {
|
||||||
|
return { error, reloading: true };
|
||||||
|
}
|
||||||
|
return { error, reloading: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
@@ -17,6 +21,18 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
if (this.state.reloading) {
|
||||||
|
return (
|
||||||
|
<div className="error-page-shell">
|
||||||
|
<div className="error-page">
|
||||||
|
<div className="error-page__code" aria-hidden>…</div>
|
||||||
|
<h1 className="error-page__title">正在更新页面</h1>
|
||||||
|
<p className="error-page__desc">检测到程序已更新,正在自动刷新以加载最新版本…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return (
|
return (
|
||||||
<div className="error-page-shell">
|
<div className="error-page-shell">
|
||||||
|
|||||||
205
frontend/src/components/PullToRefresh.tsx
Normal file
205
frontend/src/components/PullToRefresh.tsx
Normal file
@@ -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<HTMLElement>('.post-list-scroll');
|
||||||
|
if (list) return list;
|
||||||
|
|
||||||
|
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
||||||
|
if (page) return page;
|
||||||
|
|
||||||
|
const compose = document.querySelector<HTMLElement>('.main-content--compose');
|
||||||
|
if (compose) return compose;
|
||||||
|
|
||||||
|
const admin = document.querySelector<HTMLElement>('.admin-main');
|
||||||
|
if (admin) return admin;
|
||||||
|
|
||||||
|
const auth = document.querySelector<HTMLElement>('.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<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pullRef.current = pull;
|
||||||
|
}, [pull]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshingRef.current = refreshing;
|
||||||
|
}, [refreshing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTouchDevice()) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div
|
||||||
|
className={`ptr-indicator${ready ? ' ptr-indicator--ready' : ''}${refreshing ? ' ptr-indicator--refreshing' : ''}`}
|
||||||
|
style={{ transform: `translateY(${offset}px)` }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="ptr-indicator__chip">
|
||||||
|
{refreshing ? (
|
||||||
|
<Loader2 size={18} className="ptr-indicator__spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ArrowDown
|
||||||
|
size={18}
|
||||||
|
className="ptr-indicator__arrow"
|
||||||
|
style={{ transform: ready ? 'rotate(180deg)' : undefined }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -303,6 +303,7 @@ export default function ComposePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="compose-shell-body">
|
||||||
<section className="compose-context" aria-label="发布设置">
|
<section className="compose-context" aria-label="发布设置">
|
||||||
<div className="compose-context-row">
|
<div className="compose-context-row">
|
||||||
<span className="compose-context-label">板块</span>
|
<span className="compose-context-label">板块</span>
|
||||||
@@ -347,6 +348,7 @@ export default function ComposePage() {
|
|||||||
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
|
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UnsavedChangesDialog
|
<UnsavedChangesDialog
|
||||||
|
|||||||
@@ -1470,6 +1470,57 @@ img.site-brand-logo-img {
|
|||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机端自定义下拉刷新指示器(原生 PTR 依赖 document 滚动) */
|
||||||
|
.ptr-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptr-indicator__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--j13-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptr-indicator--ready .ptr-indicator__chip {
|
||||||
|
color: var(--j13-green);
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 35%, var(--j13-border));
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptr-indicator__arrow {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptr-indicator__spin {
|
||||||
|
animation: ptr-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ptr-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.ptr-indicator { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.back-to-top-stack {
|
.back-to-top-stack {
|
||||||
right: 14px;
|
right: 14px;
|
||||||
@@ -5809,10 +5860,11 @@ button.profile-stat:hover strong {
|
|||||||
.compose-page {
|
.compose-page {
|
||||||
/* 与 .compose-header 实际高度对齐,供编辑器工具栏 sticky 偏移 */
|
/* 与 .compose-header 实际高度对齐,供编辑器工具栏 sticky 偏移 */
|
||||||
--compose-header-sticky-h: 57px;
|
--compose-header-sticky-h: 57px;
|
||||||
flex: 1;
|
/* 随正文增高,避免白底被视口高度裁切、粘性顶栏失效 */
|
||||||
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: min(100%, 100vh - var(--j13-header-h));
|
min-height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
background: var(--j13-bg-workspace);
|
background: var(--j13-bg-workspace);
|
||||||
}
|
}
|
||||||
@@ -5880,15 +5932,16 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-canvas {
|
.compose-canvas {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: min(100%, 100vh - var(--j13-header-h));
|
min-height: 100%;
|
||||||
/* 富文本:接近阅读宽度;源码双栏时拉宽 */
|
/* 富文本:接近正文阅读宽度,避免宽行/表格撑出右侧;源码双栏另加宽 */
|
||||||
max-width: 1120px;
|
max-width: calc(var(--j13-article-read-w) + 64px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px 20px 24px;
|
padding: 16px 20px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
transition: max-width 0.2s ease;
|
transition: max-width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5897,17 +5950,33 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell {
|
.compose-shell {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
/* 短文填满可视区;长文随内容增高(勿用 min-height:0,否则白底被裁切) */
|
||||||
|
min-height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: var(--j13-shadow-card);
|
box-shadow: var(--j13-shadow-card);
|
||||||
|
/* 顶栏 sticky 需 visible;横向溢出由 .compose-shell-body 承接 */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 正文区单独限宽,避免表格/长词撑出白底 */
|
||||||
|
.compose-shell-body {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-header {
|
.compose-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -5921,6 +5990,8 @@ button.profile-stat:hover strong {
|
|||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
border-bottom: 1px solid var(--j13-border-light);
|
border-bottom: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px 12px 0 0;
|
border-radius: 12px 12px 0 0;
|
||||||
|
/* 长文下滚时顶栏与正文分层更清晰 */
|
||||||
|
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-header-left {
|
.compose-header-left {
|
||||||
@@ -6199,11 +6270,12 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-document {
|
.compose-document {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-width: 0;
|
||||||
padding: 0 0 0;
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-title {
|
.compose-title {
|
||||||
@@ -6238,9 +6310,11 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
/* 文章编辑器 */
|
/* 文章编辑器 */
|
||||||
.article-editor {
|
.article-editor {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
border-top: 1px solid var(--j13-border-light);
|
border-top: 1px solid var(--j13-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6407,20 +6481,25 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-body {
|
.article-editor-body {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑面板 */
|
/* 编辑面板 */
|
||||||
.article-editor-pane {
|
.article-editor-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
/* 普通模式随正文增高,由外层 .main-content--compose 滚动 */
|
||||||
|
overflow: visible;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
@@ -6457,9 +6536,11 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-scroll {
|
.article-editor-scroll {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: auto;
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6476,11 +6557,32 @@ button.profile-stat:hover strong {
|
|||||||
.article-editor-content .tiptap,
|
.article-editor-content .tiptap,
|
||||||
.article-prosemirror {
|
.article-prosemirror {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
padding: 16px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
/* 长中英混排/表格单元格:强制在容器内换行,不撑破白底 */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑器内表格:允许单元格换行,过宽时在编辑区内横滑 */
|
||||||
|
.article-prosemirror.post-detail-content table,
|
||||||
|
.article-prosemirror table {
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prosemirror.post-detail-content th,
|
||||||
|
.article-prosemirror.post-detail-content td,
|
||||||
|
.article-prosemirror th,
|
||||||
|
.article-prosemirror td {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 源码双栏:充分利用加宽画布 */
|
/* 源码双栏:充分利用加宽画布 */
|
||||||
@@ -6902,6 +7004,13 @@ button.profile-stat:hover strong {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-editor--fullscreen .article-editor-pane,
|
||||||
|
.article-editor--fullscreen .article-editor-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全屏富文本:编辑区居中,宽度对齐帖子正文阅读区 */
|
/* 全屏富文本:编辑区居中,宽度对齐帖子正文阅读区 */
|
||||||
.article-editor--fullscreen.article-editor--rich .article-editor-body {
|
.article-editor--fullscreen.article-editor--rich .article-editor-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
34
frontend/src/utils/chunkLoad.ts
Normal file
34
frontend/src/utils/chunkLoad.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** sessionStorage:短时内只自动刷新一次,避免死循环 */
|
||||||
|
const RELOAD_AT_KEY = 'j13:chunk-reload-at';
|
||||||
|
const RELOAD_COOLDOWN_MS = 15_000;
|
||||||
|
|
||||||
|
/** 判断是否为「发版后旧 JS chunk 失效」类错误 */
|
||||||
|
export function isChunkLoadError(error: unknown): boolean {
|
||||||
|
const msg = error instanceof Error
|
||||||
|
? `${error.name} ${error.message}`
|
||||||
|
: String(error ?? '');
|
||||||
|
return /Failed to fetch dynamically imported module/i.test(msg)
|
||||||
|
|| /error loading dynamically imported module/i.test(msg)
|
||||||
|
|| /Importing a module script failed/i.test(msg)
|
||||||
|
|| /Loading chunk [\w-]+ failed/i.test(msg)
|
||||||
|
|| /ChunkLoadError/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发版后旧页面仍引用已删除的 hashed chunk 时,整页刷新以拉取新 index.html。
|
||||||
|
* @returns 是否已触发刷新(调用方应暂停渲染)
|
||||||
|
*/
|
||||||
|
export function reloadForStaleChunk(): boolean {
|
||||||
|
try {
|
||||||
|
const prev = Number(sessionStorage.getItem(RELOAD_AT_KEY) || 0);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - prev < RELOAD_COOLDOWN_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage 不可用时仍尝试刷新一次
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
25
frontend/src/utils/lazyWithRetry.ts
Normal file
25
frontend/src/utils/lazyWithRetry.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
|
||||||
|
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyComponent = ComponentType<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带发版容错的 React.lazy:动态 import 失败时自动整页刷新一次。
|
||||||
|
* (部署后 hashed chunk 更名,旧标签页点到懒加载路由时常见)
|
||||||
|
*/
|
||||||
|
export function lazyWithRetry<T extends AnyComponent>(
|
||||||
|
factory: () => Promise<{ default: T }>,
|
||||||
|
): LazyExoticComponent<T> {
|
||||||
|
return lazy(async () => {
|
||||||
|
try {
|
||||||
|
return await factory();
|
||||||
|
} catch (err) {
|
||||||
|
if (isChunkLoadError(err) && reloadForStaleChunk()) {
|
||||||
|
// 刷新进行中,挂起 Promise,避免再抛到错误页闪一下
|
||||||
|
return new Promise<{ default: T }>(() => {});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user