Compare commits
34 Commits
v1.1.5
...
rebuild/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 20aee9c566 | |||
| 45cc864971 | |||
| 3c6d8cb891 | |||
| 2392768868 | |||
| 3702e3d1f2 | |||
| 230006a905 | |||
| ba39376a0e | |||
| 1ce0f123fa | |||
| c99238c1e7 | |||
| e71c77b5ca | |||
| 27132e89c2 | |||
| 86a317c862 | |||
| 52d328ffc3 | |||
| 711cd8b759 | |||
| de2f5e8be8 | |||
| 5c774f9a83 | |||
| f3f50d7e90 | |||
| 67bef6dc08 | |||
| 23e304ac53 | |||
| cad53942e8 | |||
| 76be8926f2 | |||
| 4d93e455f9 | |||
| c958dcfd80 | |||
| 84bfbd9a3e | |||
| 500e452c2f | |||
| 1c0f7ede55 | |||
| 2e9c42a34c | |||
| dff60dde6a | |||
| ea9e0058d4 | |||
| d5aa36416c | |||
| 2159280af5 | |||
| bc9a031890 | |||
| 0d81787125 | |||
| 68713c053b |
@@ -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/
|
||||||
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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,9 +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
|
||||||
|
|
||||||
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
|
||||||
frontend/tmp-lucide-paths.json
|
|
||||||
|
|||||||
@@ -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 列密度更高
|
|
||||||
- 颜文字标签内字体清晰可读
|
|
||||||
@@ -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 内容
|
|
||||||
- 保存后评论更新正确
|
|
||||||
@@ -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`),验证定位效果
|
|
||||||
@@ -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 编译通过。
|
|
||||||
@@ -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 静态文件服务方案
|
|
||||||
36
.trae/rules/build-scripts.mdc
Normal file
36
.trae/rules/build-scripts.mdc
Normal 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「快速开始」如需对外暴露再更新
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "de7e4cff",
|
|
||||||
"configHash": "9a7296da",
|
|
||||||
"lockfileHash": "e3b0c442",
|
|
||||||
"browserHash": "8c168d3c",
|
|
||||||
"optimized": {},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
39
Dockerfile
39
Dockerfile
@@ -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"]
|
||||||
|
|||||||
48
Makefile
48
Makefile
@@ -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.1.5
|
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 启动"
|
||||||
|
|||||||
146
README.md
146
README.md
@@ -13,13 +13,14 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](frontend/package.json)
|
[](frontend/package.json)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
[在线演示](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,33 +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_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
| `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(手动):**
|
||||||
|
|
||||||
@@ -206,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.1.5
|
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.1.5 -t hangzhang714128/jiang13-forum:1.1.5 -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.1.5
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
docker push hangzhang714128/jiang13-forum:latest
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -232,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. 首次使用
|
||||||
@@ -326,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
|
||||||
@@ -345,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 渲染服务器)下才有页面。
|
||||||
|
|
||||||
### 更新截图
|
### 更新截图
|
||||||
|
|
||||||
@@ -384,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/ # 开发辅助脚本(含截图)
|
||||||
|
|||||||
48
build.ps1
48
build.ps1
@@ -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.1.5'
|
$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'
|
||||||
|
|||||||
@@ -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,9 @@ 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 {
|
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||||
return fmt.Errorf("监控库初始化失败: %w", err)
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
193
docs/site-posts/changelog.md
Normal file
193
docs/site-posts/changelog.md
Normal 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 文件,再把新版本写到本页最上方。
|
||||||
244
docs/site-posts/deployment-guide.md
Normal file
244
docs/site-posts/deployment-guide.md
Normal 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 |
|
||||||
|
|
||||||
|
有问题欢迎本帖留言。
|
||||||
@@ -1,118 +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__
|
|
||||||
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
|
||||||
func SetSPADocumentTitle(fn func() string) {
|
|
||||||
spaBrandTitleFn = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
|
||||||
func SetSPABrandingJSON(fn func() []byte) {
|
|
||||||
spaBrandJSONFn = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
|
||||||
func SetSPAFaviconURL(fn func() string) {
|
|
||||||
spaBrandFaviconFn = 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) {
|
|
||||||
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
|
||||||
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
|
||||||
c.Writer = w
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
|
|
||||||
type cacheOnOKWriter struct {
|
|
||||||
gin.ResponseWriter
|
|
||||||
cacheControl string
|
|
||||||
wroteHeader bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *cacheOnOKWriter) WriteHeader(code int) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.wroteHeader = true
|
|
||||||
if code == http.StatusOK {
|
|
||||||
w.Header().Set("Cache-Control", w.cacheControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package embed_static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
|
|
||||||
|
|
||||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
|
|
||||||
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
|
|
||||||
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
|
|
||||||
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
|
||||||
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 哈希(部分反代对 no-cache 仍会存)
|
|
||||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
c.Header("Pragma", "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>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
|
||||||
var seo strings.Builder
|
|
||||||
writeMeta(&seo, "description", meta.Description)
|
|
||||||
writeMeta(&seo, "keywords", meta.Keywords)
|
|
||||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
|
||||||
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
|
||||||
}
|
|
||||||
if favicon := spaFaviconHref(); favicon != "" {
|
|
||||||
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
|
|
||||||
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
|
|
||||||
}
|
|
||||||
robots := strings.TrimSpace(meta.Robots)
|
|
||||||
if robots != "" {
|
|
||||||
writeMeta(&seo, "robots", robots)
|
|
||||||
}
|
|
||||||
|
|
||||||
ogType := strings.TrimSpace(meta.OGType)
|
|
||||||
if ogType == "" {
|
|
||||||
ogType = "website"
|
|
||||||
}
|
|
||||||
locale := strings.TrimSpace(meta.Locale)
|
|
||||||
if locale == "" {
|
|
||||||
locale = "zh_CN"
|
|
||||||
}
|
|
||||||
writeProp(&seo, "og:type", ogType)
|
|
||||||
writeProp(&seo, "og:site_name", meta.SiteName)
|
|
||||||
writeProp(&seo, "og:locale", locale)
|
|
||||||
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
|
||||||
writeProp(&seo, "og:description", meta.Description)
|
|
||||||
writeProp(&seo, "og:url", meta.Canonical)
|
|
||||||
writeProp(&seo, "og:image", meta.OGImage)
|
|
||||||
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
|
||||||
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
|
||||||
writeMetaName(&seo, "twitter:description", meta.Description)
|
|
||||||
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
|
||||||
|
|
||||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
|
||||||
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
|
||||||
seo.WriteString(`<script type="application/ld+json">`)
|
|
||||||
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
|
||||||
seo.WriteString(`</script>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if seo.Len() > 0 {
|
|
||||||
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
|
||||||
var boot strings.Builder
|
|
||||||
if s := spaBrandingBootScript(); s != "" {
|
|
||||||
boot.WriteString(s)
|
|
||||||
}
|
|
||||||
if s := spaHomeBootScript(meta.BootJSON); s != "" {
|
|
||||||
boot.WriteString(s)
|
|
||||||
}
|
|
||||||
if boot.Len() > 0 {
|
|
||||||
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if root := strings.TrimSpace(meta.RootHTML); root != "" {
|
|
||||||
data = injectSPARootHTML(data, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
|
|
||||||
func spaHomeBootScript(raw []byte) string {
|
|
||||||
raw = bytes.TrimSpace(raw)
|
|
||||||
if len(raw) == 0 || !json.Valid(raw) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
|
||||||
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
|
|
||||||
func injectSPARootHTML(data []byte, rootHTML string) []byte {
|
|
||||||
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
|
||||||
func spaBrandingBootScript() string {
|
|
||||||
if spaBrandJSONFn == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw := bytes.TrimSpace(spaBrandJSONFn())
|
|
||||||
if len(raw) == 0 || !json.Valid(raw) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
|
||||||
func spaFaviconHref() string {
|
|
||||||
if spaBrandFaviconFn == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(spaBrandFaviconFn())
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,48 +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>
|
|
||||||
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 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>
|
|
||||||
5508
frontend/package-lock.json
generated
5508
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
"@svg-maps/china": "^2.0.0",
|
"@svg-maps/china": "^2.0.0",
|
||||||
"@svg-maps/world": "^2.0.0",
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@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",
|
||||||
@@ -32,36 +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",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vike": "^0.4.266",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^4.5.7"
|
"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
12
frontend/pages/+config.ts
Normal 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;
|
||||||
10
frontend/pages/_error/+Page.tsx
Normal file
10
frontend/pages/_error/+Page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
24
frontend/pages/_error/+data.ts
Normal file
24
frontend/pages/_error/+data.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
11
frontend/pages/feed/+Page.tsx
Normal file
11
frontend/pages/feed/+Page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
9
frontend/pages/feed/+data.ts
Normal file
9
frontend/pages/feed/+data.ts
Normal 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);
|
||||||
|
}
|
||||||
11
frontend/pages/feed/+route.ts
Normal file
11
frontend/pages/feed/+route.ts
Normal 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 } };
|
||||||
|
}
|
||||||
8
frontend/pages/spa/+Page.tsx
Normal file
8
frontend/pages/spa/+Page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import App from '../../src/App';
|
||||||
|
|
||||||
|
export { Page };
|
||||||
|
|
||||||
|
/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
|
||||||
|
function Page() {
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
10
frontend/pages/spa/+route.ts
Normal file
10
frontend/pages/spa/+route.ts
Normal 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: {} };
|
||||||
|
}
|
||||||
4
frontend/pages/spa/+ssr.ts
Normal file
4
frontend/pages/spa/+ssr.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ssr };
|
||||||
|
|
||||||
|
/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
|
||||||
|
const ssr = false;
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
98
frontend/renderer/+onRenderClient.tsx
Normal file
98
frontend/renderer/+onRenderClient.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
frontend/renderer/+onRenderHtml.tsx
Normal file
78
frontend/renderer/+onRenderHtml.tsx
Normal 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>`;
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const iconsDir = path.join('node_modules/lucide-react/dist/esm/icons');
|
|
||||||
const names = [
|
|
||||||
'search', 'house', 'star', 'folder-git-2', 'link-2', 'earth', 'file-text',
|
|
||||||
'message-circle', 'tags', 'moon', 'clock', 'badge-check', 'sliders-horizontal',
|
|
||||||
'plus', 'sun', 'mail', 'calendar-check', 'check', 'gift', 'panel-right',
|
|
||||||
'user-plus', 'layout-dashboard',
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
|
|
||||||
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
|
|
||||||
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
|
|
||||||
'folder', 'wrench', 'cpu',
|
|
||||||
];
|
|
||||||
|
|
||||||
function esc(v) {
|
|
||||||
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeToInner(nodes) {
|
|
||||||
return nodes.map(([tag, attrs]) => {
|
|
||||||
let s = `<${tag}`;
|
|
||||||
for (const [k, v] of Object.entries(attrs)) {
|
|
||||||
if (k === 'key') continue;
|
|
||||||
s += ` ${k}="${esc(v)}"`;
|
|
||||||
}
|
|
||||||
return `${s}/>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveIconFile(name) {
|
|
||||||
let f = path.join(iconsDir, `${name}.mjs`);
|
|
||||||
let src = fs.readFileSync(f, 'utf8');
|
|
||||||
const re = src.match(/export \{ default \} from '\.\/([^']+)';/);
|
|
||||||
if (re) {
|
|
||||||
f = path.join(iconsDir, re[1]);
|
|
||||||
src = fs.readFileSync(f, 'utf8');
|
|
||||||
}
|
|
||||||
return { f, src };
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = {};
|
|
||||||
for (const n of names) {
|
|
||||||
try {
|
|
||||||
const { src } = resolveIconFile(n);
|
|
||||||
const m = src.match(/const __iconNode = (\[[\s\S]*?\]);/);
|
|
||||||
if (!m) {
|
|
||||||
console.error('no node', n);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-eval
|
|
||||||
const nodes = eval(m[1]);
|
|
||||||
out[n] = nodeToInner(nodes);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('fail', n, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync('tmp-lucide-paths.json', JSON.stringify(out, null, 2));
|
|
||||||
console.log('wrote', Object.keys(out).length, 'icons');
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const paths = JSON.parse(fs.readFileSync('tmp-lucide-paths.json', 'utf8'));
|
|
||||||
|
|
||||||
function splitInner(inner) {
|
|
||||||
const tags = [];
|
|
||||||
const re = /<([a-z]+)([^>]*)\/>/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(inner))) {
|
|
||||||
tags.push(`<${m[1]}${m[2]}/>`);
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goFunc(name, size, key, className = '') {
|
|
||||||
const tags = splitInner(paths[key]);
|
|
||||||
const lines = tags.map((t) => `\t\t\`${t}\`,`).join('\n');
|
|
||||||
if (className) {
|
|
||||||
return `func ${name}() string {\n\treturn ssrSVGWithClass(${size}, "${className}",\n${lines}\n\t)\n}\n\n`;
|
|
||||||
}
|
|
||||||
return `func ${name}() string {\n\treturn ssrSVG(${size},\n${lines}\n\t)\n}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardKeys = [
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
|
|
||||||
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
|
|
||||||
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
|
|
||||||
'star', 'folder', 'wrench', 'cpu',
|
|
||||||
];
|
|
||||||
const defaults = [
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square',
|
|
||||||
'lightbulb', 'book-open', 'gamepad-2', 'palette',
|
|
||||||
];
|
|
||||||
|
|
||||||
let out = `package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
\t"strconv"
|
|
||||||
\t"strings"
|
|
||||||
\t"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取)
|
|
||||||
|
|
||||||
func ssrSVG(size int, paths ...string) string {
|
|
||||||
\treturn ssrSVGWithClass(size, "", paths...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrSVGWithClass(size int, className string, paths ...string) string {
|
|
||||||
\tvar b strings.Builder
|
|
||||||
\tb.WriteString(\`<svg xmlns="http://www.w3.org/2000/svg" width="\`)
|
|
||||||
\tb.WriteString(strconv.Itoa(size))
|
|
||||||
\tb.WriteString(\`" height="\`)
|
|
||||||
\tb.WriteString(strconv.Itoa(size))
|
|
||||||
\tb.WriteString(\`" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\`)
|
|
||||||
\tif className != "" {
|
|
||||||
\t\tb.WriteString(\` class="\`)
|
|
||||||
\t\tb.WriteString(className)
|
|
||||||
\t\tb.WriteString(\`"\`)
|
|
||||||
\t}
|
|
||||||
\tb.WriteString(\` aria-hidden="true">\`)
|
|
||||||
\tfor _, p := range paths {
|
|
||||||
\t\tb.WriteString(p)
|
|
||||||
\t}
|
|
||||||
\tb.WriteString(\`</svg>\`)
|
|
||||||
\treturn b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
out += goFunc('ssrIconSearch', 16, 'search', 'header-search-icon');
|
|
||||||
out += goFunc('ssrIconSliders', 15, 'sliders-horizontal');
|
|
||||||
out += goFunc('ssrIconPlus', 16, 'plus');
|
|
||||||
out += goFunc('ssrIconMoon', 18, 'moon');
|
|
||||||
out += goFunc('ssrIconSun', 18, 'sun');
|
|
||||||
out += goFunc('ssrIconMail', 18, 'mail');
|
|
||||||
out += goFunc('ssrIconHome', 18, 'house');
|
|
||||||
out += goFunc('ssrIconStar', 18, 'star');
|
|
||||||
out += goFunc('ssrIconFolderGit', 18, 'folder-git-2');
|
|
||||||
out += goFunc('ssrIconLink2', 18, 'link-2');
|
|
||||||
out += goFunc('ssrIconEarth', 18, 'earth');
|
|
||||||
out += goFunc('ssrIconFileText', 18, 'file-text');
|
|
||||||
out += goFunc('ssrIconLayoutDashboard', 18, 'layout-dashboard');
|
|
||||||
out += goFunc('ssrIconMessageCircle', 16, 'message-circle');
|
|
||||||
out += goFunc('ssrIconClock', 16, 'clock');
|
|
||||||
out += goFunc('ssrIconBadgeCheck', 16, 'badge-check');
|
|
||||||
out += goFunc('ssrIconTags', 16, 'tags');
|
|
||||||
out += goFunc('ssrIconUserPlus', 16, 'user-plus');
|
|
||||||
out += goFunc('ssrIconCalendarCheck', 18, 'calendar-check');
|
|
||||||
out += goFunc('ssrIconCheck', 18, 'check');
|
|
||||||
out += goFunc('ssrIconGift', 15, 'gift');
|
|
||||||
out += goFunc('ssrIconPanelRight', 18, 'panel-right');
|
|
||||||
|
|
||||||
out += '// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons)\nvar ssrBoardIconInner = map[string]string{\n';
|
|
||||||
for (const k of boardKeys) {
|
|
||||||
out += `\t"${k}": \`${paths[k]}\`,\n`;
|
|
||||||
}
|
|
||||||
out += '}\n\n';
|
|
||||||
|
|
||||||
out += '// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)\nvar ssrBoardDefaultIcons = []string{\n';
|
|
||||||
for (const k of defaults) {
|
|
||||||
out += `\t"${k}",\n`;
|
|
||||||
}
|
|
||||||
out += '}\n\n';
|
|
||||||
|
|
||||||
out += `// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致)
|
|
||||||
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
|
|
||||||
\tkey := strings.TrimSpace(strings.ToLower(icon))
|
|
||||||
\tinner, ok := ssrBoardIconInner[key]
|
|
||||||
\tif !ok || inner == "" {
|
|
||||||
\t\tif themeIndex < 0 {
|
|
||||||
\t\t\tthemeIndex = 0
|
|
||||||
\t\t}
|
|
||||||
\t\tkey = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
|
|
||||||
\t\tinner = ssrBoardIconInner[key]
|
|
||||||
\t}
|
|
||||||
\treturn ssrSVGWithClass(18, className, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRRelativeTime 与前端 formatTime 同规则
|
|
||||||
func formatSSRRelativeTime(t time.Time) string {
|
|
||||||
\tif t.IsZero() {
|
|
||||||
\t\treturn ""
|
|
||||||
\t}
|
|
||||||
\tnow := time.Now()
|
|
||||||
\tdiffSec := now.Sub(t).Seconds()
|
|
||||||
\tif diffSec < 0 {
|
|
||||||
\t\tdiffSec = 0
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 60 {
|
|
||||||
\t\treturn "刚刚"
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 3600 {
|
|
||||||
\t\treturn strconv.Itoa(int(diffSec/60)) + "分钟前"
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 86400 {
|
|
||||||
\t\treturn strconv.Itoa(int(diffSec/3600)) + "小时前"
|
|
||||||
\t}
|
|
||||||
\tdiffDay := int(diffSec / 86400)
|
|
||||||
\tif diffDay < 30 {
|
|
||||||
\t\treturn strconv.Itoa(diffDay) + "天前"
|
|
||||||
\t}
|
|
||||||
\tif t.Year() == now.Year() {
|
|
||||||
\t\treturn strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
\t}
|
|
||||||
\treturn strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm)
|
|
||||||
func formatSSRShortDateTime(t time.Time) string {
|
|
||||||
\tif t.IsZero() {
|
|
||||||
\t\treturn ""
|
|
||||||
\t}
|
|
||||||
\tlocal := t.Local()
|
|
||||||
\tpad := func(n int) string {
|
|
||||||
\t\tif n < 10 {
|
|
||||||
\t\t\treturn "0" + strconv.Itoa(n)
|
|
||||||
\t\t}
|
|
||||||
\t\treturn strconv.Itoa(n)
|
|
||||||
\t}
|
|
||||||
\treturn pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const outPath = path.join('..', 'handler', 'ssr_icons.go');
|
|
||||||
fs.writeFileSync(outPath, out);
|
|
||||||
console.log('wrote', outPath, Object.keys(paths).length, 'icons');
|
|
||||||
121
frontend/server/index.mjs
Normal file
121
frontend/server/index.mjs
Normal 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})`);
|
||||||
|
});
|
||||||
@@ -1,114 +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 TopProgressBar from './components/TopProgressBar';
|
|
||||||
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 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'));
|
|
||||||
|
|
||||||
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="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 />} />
|
|
||||||
<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="/showcase" element={<Suspense fallback={null}><ShowcasePage /></Suspense>} />
|
|
||||||
<Route path="/messages" element={<MessagesPage />} />
|
|
||||||
<Route path="/page/:slug" element={<Suspense fallback={null}><SitePageView /></Suspense>} />
|
|
||||||
<Route path="*" element={<Suspense fallback={null}><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>
|
||||||
<TopProgressBar />
|
</BrowserRouter>
|
||||||
<PullToRefresh />
|
);
|
||||||
<Toaster />
|
}
|
||||||
</ErrorBoundary>
|
|
||||||
</AuthProvider>
|
/**
|
||||||
</ThemeProvider>
|
* SSR 应用工厂:低层 StaticRouter + useRoutes,location 一次性给定,
|
||||||
|
* 整棵树同步渲染(无首导航异步、无状态订阅 suspend)。
|
||||||
|
*/
|
||||||
|
export async function createSsrApp(url: string) {
|
||||||
|
return (
|
||||||
|
<StaticRouter location={url}>
|
||||||
|
<AppShell>
|
||||||
|
<AppRoutes />
|
||||||
|
</AppShell>
|
||||||
|
</StaticRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
89
frontend/src/AppRoutes.tsx
Normal file
89
frontend/src/AppRoutes.tsx
Normal 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
24
frontend/src/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -154,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) =>
|
||||||
@@ -264,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();
|
||||||
@@ -419,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;
|
||||||
@@ -574,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' }),
|
||||||
@@ -656,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),
|
||||||
|
|||||||
40
frontend/src/api/serverClient.ts
Normal file
40
frontend/src/api/serverClient.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
/** 禁止新评论(结贴) */
|
/** 禁止新评论(结贴) */
|
||||||
@@ -224,7 +229,7 @@ export interface FeedSortTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [
|
export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [
|
||||||
{ id: 'reply', label: '新评论', enabled: true },
|
{ id: 'reply', label: '最新', enabled: true },
|
||||||
{ id: 'latest', label: '新帖子', enabled: true },
|
{ id: 'latest', label: '新帖子', enabled: true },
|
||||||
{ id: 'hot', label: '推荐帖', enabled: true },
|
{ id: 'hot', label: '推荐帖', enabled: true },
|
||||||
];
|
];
|
||||||
@@ -717,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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? `` : '');
|
||||||
|
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\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\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}
|
||||||
|
|||||||
@@ -1,5 +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 { 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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
|
import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
|
||||||
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
|
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
|
||||||
@@ -15,7 +15,7 @@ import type { FeedSortTab } from '../api/types';
|
|||||||
export type FeedSort = 'latest' | 'reply' | 'hot';
|
export type FeedSort = 'latest' | 'reply' | 'hot';
|
||||||
|
|
||||||
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
|
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
|
||||||
reply: { hint: '最近有人评论', icon: MessageCircle },
|
reply: { hint: '按最近活动(发帖或评论)', icon: MessageCircle },
|
||||||
latest: { hint: '按发帖时间', icon: Clock },
|
latest: { hint: '按发帖时间', icon: Clock },
|
||||||
hot: { hint: '仅展示推荐帖', icon: BadgeCheck },
|
hot: { hint: '仅展示推荐帖', icon: BadgeCheck },
|
||||||
};
|
};
|
||||||
|
|||||||
227
frontend/src/components/PmComposerInput.tsx
Normal file
227
frontend/src/components/PmComposerInput.tsx
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += ``;
|
||||||
|
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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -71,10 +72,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
|
|
||||||
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 && (
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -151,11 +151,11 @@ export default function PostManageMenu({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onPin}>
|
<DropdownMenuItem onSelect={onPin}>
|
||||||
<Pin size={14} aria-hidden />
|
<Pin size={14} aria-hidden />
|
||||||
{post.pinned ? '取消全局置顶' : '全局置顶'}
|
{post.pinned > 0 ? '取消全局置顶' : '全局置顶'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onBoardPin}>
|
<DropdownMenuItem onSelect={onBoardPin}>
|
||||||
<Pin size={14} aria-hidden />
|
<Pin size={14} aria-hidden />
|
||||||
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
|
{(post.board_pinned ?? 0) > 0 ? '取消板块置顶' : '板块置顶'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -21,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;
|
||||||
@@ -31,7 +37,7 @@ 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-page');
|
const showcase = document.querySelector<HTMLElement>('.showcase-panel');
|
||||||
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
|
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
|
||||||
|
|
||||||
const admin = document.querySelector<HTMLElement>('.admin-main');
|
const admin = document.querySelector<HTMLElement>('.admin-main');
|
||||||
|
|||||||
@@ -1,7 +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 type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
|
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, UserActivityStats, UserPublic, 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';
|
||||||
@@ -20,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 {
|
||||||
@@ -232,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 />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Globe2 } from 'lucide-react';
|
import { Globe2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { CommunityShowcaseItem } from '../api/types';
|
import type { CommunityShowcaseItem } from '../api/types';
|
||||||
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
@@ -19,12 +19,14 @@ export default function ShowcaseAsideWidget() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
||||||
if (hit !== undefined) {
|
// 非空快照直接用;空数组可能是启动竞态假空,仍再拉
|
||||||
|
if (hit !== undefined && hit.length > 0) {
|
||||||
setItems(hit);
|
setItems(hit);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
if (hit !== undefined) setItems(hit);
|
||||||
|
setLoading(hit === undefined);
|
||||||
api.communityShowcase()
|
api.communityShowcase()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -33,10 +35,8 @@ export default function ShowcaseAsideWidget() {
|
|||||||
setItems(next);
|
setItems(next);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
// 失败不写空快照,避免假空粘死
|
||||||
setSessionSnapshot(SHOWCASE_KEY, []);
|
if (!cancelled && hit === undefined) setItems([]);
|
||||||
setItems([]);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -54,17 +54,13 @@ export default function ShowcaseAsideWidget() {
|
|||||||
setItems(next);
|
setItems(next);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (opts.showLoading) {
|
// 软刷新 / 强刷失败:保持旧 UI,不写空快照
|
||||||
setSessionSnapshot(SHOWCASE_KEY, []);
|
|
||||||
setItems([]);
|
|
||||||
}
|
|
||||||
// 软刷新失败:保持旧 UI
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
const applyHitOrReload = (showLoading: boolean) => {
|
const applyHitOrReload = (showLoading: boolean) => {
|
||||||
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
||||||
if (hit !== undefined) {
|
if (hit !== undefined && hit.length > 0) {
|
||||||
setItems(hit);
|
setItems(hit);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
|
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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import type { PostItem } from '../api/types';
|
|
||||||
import type { FeedSort } from './FeedSortBar';
|
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
|
||||||
import { postPath } from '../utils/permalink';
|
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
|
||||||
|
|
||||||
import { formatTime } from '../utils/content';
|
|
||||||
import { MessageCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
/** 与 Go formatSSRRelativeTime / 前端 formatTime 对齐的列表时间 */
|
|
||||||
|
|
||||||
function stripHtmlPlain(html: string): string {
|
|
||||||
if (!html) return '';
|
|
||||||
const t = html
|
|
||||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
||||||
.replace(/<[^>]+>/g, ' ')
|
|
||||||
.replace(/ /gi, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateRunes(s: string, max: number): string {
|
|
||||||
const chars = Array.from(s);
|
|
||||||
if (chars.length <= max) return s;
|
|
||||||
return chars.slice(0, max).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstImageSrc(html: string): string {
|
|
||||||
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
||||||
const src = m?.[1]?.trim() || '';
|
|
||||||
if (!src || src.startsWith('data:')) return '';
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayName(post: PostItem): string {
|
|
||||||
const n = post.user?.nickname?.trim() || post.user?.username?.trim();
|
|
||||||
return n || '用户';
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
posts: PostItem[];
|
|
||||||
sort: FeedSort;
|
|
||||||
boardId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 与 Go writeSSRPostRow 同构的静态列表(hydrate 首帧专用) */
|
|
||||||
export default function StaticFeedList({ posts, sort, boardId }: Props) {
|
|
||||||
const { limits } = useForumLimits();
|
|
||||||
const style = limits.feed_list_style ?? 'title';
|
|
||||||
const titleOnly = style === 'title';
|
|
||||||
const needExcerpt = style === 'excerpt' || style === 'thumbnail';
|
|
||||||
const needThumb = style === 'thumbnail';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="post-list-scroll post-list-scroll--ssr">
|
|
||||||
<div className="content-surface content-surface--ssr">
|
|
||||||
{posts.length === 0 ? (
|
|
||||||
<div className="feed-empty">暂无帖子</div>
|
|
||||||
) : (
|
|
||||||
posts.map((post) => {
|
|
||||||
const href = postPath(post.id, limits);
|
|
||||||
const author = displayName(post);
|
|
||||||
const initial = Array.from(author)[0] || '?';
|
|
||||||
let excerpt = '';
|
|
||||||
let thumb = '';
|
|
||||||
if (needExcerpt || needThumb) {
|
|
||||||
excerpt = truncateRunes(stripHtmlPlain(post.content || ''), 120);
|
|
||||||
}
|
|
||||||
if (needThumb) thumb = firstImageSrc(post.content || '');
|
|
||||||
|
|
||||||
const rowClass = [
|
|
||||||
'post-row',
|
|
||||||
'post-row--v2',
|
|
||||||
titleOnly ? 'post-row--title-only' : '',
|
|
||||||
thumb ? 'post-row--has-thumb' : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
let timeLabel = formatTime(post.created_at);
|
|
||||||
if (sort === 'reply' && !post.last_reply_at) {
|
|
||||||
timeLabel = '暂无回复';
|
|
||||||
}
|
|
||||||
const lastReplyName = post.last_reply_user?.nickname?.trim()
|
|
||||||
|| post.last_reply_user?.username?.trim()
|
|
||||||
|| post.last_reply_guest_nick?.trim()
|
|
||||||
|| '';
|
|
||||||
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
|
|
||||||
const commentCount = post.comment_count ?? 0;
|
|
||||||
|
|
||||||
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 = (
|
|
||||||
<div className="post-title-row">
|
|
||||||
{post.pinned ? (
|
|
||||||
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
|
||||||
) : null}
|
|
||||||
{post.board_pinned ? (
|
|
||||||
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
|
||||||
) : null}
|
|
||||||
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
|
|
||||||
{post.status === 'pending' ? (
|
|
||||||
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
|
||||||
) : null}
|
|
||||||
{post.status === 'rejected' ? (
|
|
||||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
|
||||||
) : null}
|
|
||||||
<span className="post-title">{post.title}</span>
|
|
||||||
{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>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'poll' ? (
|
|
||||||
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
|
|
||||||
) : null}
|
|
||||||
{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>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'bounty' && post.bounty_status === 'awarded' ? (
|
|
||||||
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'lottery' ? (
|
|
||||||
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
|
|
||||||
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const metaLeft = (
|
|
||||||
<div className="post-meta-left">
|
|
||||||
{boardId === 0 && post.board?.id ? (
|
|
||||||
<span className="post-list-board-btn">
|
|
||||||
<span
|
|
||||||
className={`post-list-board-badge board-badge board-badge--${getBoardThemeIndex(post.board)}`}
|
|
||||||
>
|
|
||||||
{post.board.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="post-meta-author">{author}</span>
|
|
||||||
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
|
|
||||||
<span className="post-meta-time post-meta-time--created">{timeLabel}</span>
|
|
||||||
{showLastReply ? (
|
|
||||||
<span className="post-meta-last-reply">
|
|
||||||
<span className="post-meta-last-reply-arrow" aria-hidden>←</span>
|
|
||||||
{/* 外层是 <a class="post-row">,禁止嵌套 a */}
|
|
||||||
<span className="post-meta-last-reply-user">{lastReplyName}</span>
|
|
||||||
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = (
|
|
||||||
<div className="post-stats">
|
|
||||||
<span
|
|
||||||
className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}
|
|
||||||
title="评论"
|
|
||||||
>
|
|
||||||
<MessageCircle aria-hidden />
|
|
||||||
{commentCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a key={post.id} className={rowClass} href={href}>
|
|
||||||
{post.user?.avatar ? (
|
|
||||||
<span className="post-avatar user-link--avatar-only">
|
|
||||||
<img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="post-avatar user-link--avatar-only">{initial}</span>
|
|
||||||
)}
|
|
||||||
{thumb ? (
|
|
||||||
<div className="post-main post-main--with-thumb">
|
|
||||||
<div className="post-content">
|
|
||||||
{titleRow}
|
|
||||||
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
|
|
||||||
<div className="post-meta post-meta--inline">
|
|
||||||
{metaLeft}
|
|
||||||
{stats}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="post-aside">
|
|
||||||
<div className="post-thumb" aria-hidden>
|
|
||||||
<img src={thumb} alt="" loading="lazy" decoding="async" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="post-main">
|
|
||||||
<div className="post-text">
|
|
||||||
{titleRow}
|
|
||||||
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
|
|
||||||
</div>
|
|
||||||
<div className="post-meta">
|
|
||||||
{metaLeft}
|
|
||||||
{stats}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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 { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,316 +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 { 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';
|
|
||||||
import { getDefaultFeedSortFromCache } 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 = getDefaultFeedSortFromCache(),
|
|
||||||
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(() => {
|
|
||||||
// 收到新的恢复目标时允许再次 restore;null 表示已消费,勿动标记
|
|
||||||
if (restoreScrollTop == null) return;
|
|
||||||
restoredRef.current = false;
|
|
||||||
}, [restoreScrollTop]);
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
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 ? null : 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import type { FeedSortId, FeedSortTab } from '../../api/types';
|
|||||||
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
|
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
|
||||||
|
|
||||||
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
|
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
|
||||||
reply: { hint: '按最后评论时间', placeholder: '新评论' },
|
reply: { hint: '按最近活动(发帖或评论)', placeholder: '最新' },
|
||||||
latest: { hint: '按发帖时间', placeholder: '新帖子' },
|
latest: { hint: '按发帖时间', placeholder: '新帖子' },
|
||||||
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
|
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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?.(),
|
||||||
|
|||||||
351
frontend/src/components/editor/ArticleImagePickerDialog.tsx
Normal file
351
frontend/src/components/editor/ArticleImagePickerDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
54
frontend/src/components/editor/ArticleStickerExtension.ts
Normal file
54
frontend/src/components/editor/ArticleStickerExtension.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ['怀疑', '思考'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 有 SSR 种子时仍后台校验;无种子则首屏拉取
|
// SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求;
|
||||||
|
// 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。
|
||||||
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
|
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
|
||||||
useEffect(() => { void refresh(); }, [refresh]);
|
useEffect(() => {
|
||||||
|
if (peekAuthSeed().hasSeed) return;
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const prevUserId = useRef<number | null | 'init'>('init');
|
const prevUserId = useRef<number | null | 'init'>('init');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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, DEFAULT_FEED_SORT_TABS } 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,
|
||||||
@@ -38,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;
|
||||||
@@ -53,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(() => {
|
||||||
@@ -94,8 +101,13 @@ export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promi
|
|||||||
return limits;
|
return limits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
/** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */
|
||||||
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.limits = limits;
|
||||||
|
return;
|
||||||
|
}
|
||||||
cached = limits;
|
cached = limits;
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
@@ -110,5 +122,5 @@ export function invalidateForumLimitsCache() {
|
|||||||
|
|
||||||
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
||||||
export function getCachedForumLimits(): ForumLimitsPublic {
|
export function getCachedForumLimits(): ForumLimitsPublic {
|
||||||
return cached ?? DEFAULT_LIMITS;
|
return currentLimits() ?? DEFAULT_LIMITS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useForumLimits } from './useForumLimits';
|
import { useForumLimits } from './useForumLimits';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 { transitionTo } from '../utils/spaTransition';
|
||||||
|
|||||||
@@ -13,15 +13,22 @@ type Fetcher<T> = () => Promise<T>;
|
|||||||
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
|
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
|
||||||
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
|
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
|
||||||
*/
|
*/
|
||||||
|
function isEmptyArray(value: unknown): boolean {
|
||||||
|
return Array.isArray(value) && value.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function useSessionResource<T>(
|
export function useSessionResource<T>(
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fetcher: Fetcher<T>,
|
fetcher: Fetcher<T>,
|
||||||
opts?: {
|
opts?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onError?: (e: unknown) => void;
|
onError?: (e: unknown) => void;
|
||||||
|
/** 缓存为空数组时仍后台再拉,避免启动竞态假空粘死 */
|
||||||
|
revalidateEmpty?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const enabled = opts?.enabled !== false;
|
const enabled = opts?.enabled !== false;
|
||||||
|
const revalidateEmpty = opts?.revalidateEmpty === true;
|
||||||
const fetcherRef = useRef(fetcher);
|
const fetcherRef = useRef(fetcher);
|
||||||
fetcherRef.current = fetcher;
|
fetcherRef.current = fetcher;
|
||||||
const onErrorRef = useRef(opts?.onError);
|
const onErrorRef = useRef(opts?.onError);
|
||||||
@@ -49,7 +56,7 @@ export function useSessionResource<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hit = getSessionSnapshot<T>(key);
|
const hit = getSessionSnapshot<T>(key);
|
||||||
if (hit !== undefined) {
|
if (hit !== undefined && !(revalidateEmpty && isEmptyArray(hit))) {
|
||||||
setData(hit);
|
setData(hit);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
@@ -57,9 +64,16 @@ export function useSessionResource<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seq = ++seqRef.current;
|
const seq = ++seqRef.current;
|
||||||
const keep = dataRef.current !== undefined;
|
const keep = dataRef.current !== undefined || (hit !== undefined && isEmptyArray(hit));
|
||||||
if (keep) setPending(true);
|
if (hit !== undefined && isEmptyArray(hit)) {
|
||||||
else setLoading(true);
|
setData(hit);
|
||||||
|
setPending(true);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (keep && dataRef.current !== undefined) {
|
||||||
|
setPending(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
fetcherRef.current()
|
fetcherRef.current()
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
@@ -70,6 +84,7 @@ export function useSessionResource<T>(
|
|||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
if (seq !== seqRef.current) return;
|
if (seq !== seqRef.current) return;
|
||||||
onErrorRef.current?.(e);
|
onErrorRef.current?.(e);
|
||||||
|
// 失败不写空快照;已有画面则保留
|
||||||
if (!keep) setData(undefined);
|
if (!keep) setData(undefined);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -81,7 +96,7 @@ export function useSessionResource<T>(
|
|||||||
return () => {
|
return () => {
|
||||||
seqRef.current += 1;
|
seqRef.current += 1;
|
||||||
};
|
};
|
||||||
}, [key, enabled]);
|
}, [key, enabled, revalidateEmpty]);
|
||||||
|
|
||||||
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
|
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
@@ -99,18 +114,20 @@ export function useSessionResource<T>(
|
|||||||
const reload = (opts: { allowLoading: boolean }) => {
|
const reload = (opts: { allowLoading: boolean }) => {
|
||||||
if (!key || !enabled) return;
|
if (!key || !enabled) return;
|
||||||
const warm = getSessionSnapshot<T>(key);
|
const warm = getSessionSnapshot<T>(key);
|
||||||
if (warm !== undefined) {
|
if (warm !== undefined && !(revalidateEmpty && isEmptyArray(warm))) {
|
||||||
setData(warm);
|
setData(warm);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seq = ++seqRef.current;
|
const seq = ++seqRef.current;
|
||||||
const keep = dataRef.current !== undefined;
|
const keep = dataRef.current !== undefined || (warm !== undefined && isEmptyArray(warm));
|
||||||
if (opts.allowLoading) {
|
if (opts.allowLoading) {
|
||||||
deleteSessionSnapshot(key);
|
deleteSessionSnapshot(key);
|
||||||
if (keep) setPending(true);
|
if (keep) setPending(true);
|
||||||
else setLoading(true);
|
else setLoading(true);
|
||||||
|
} else if (warm !== undefined && isEmptyArray(warm)) {
|
||||||
|
setPending(true);
|
||||||
}
|
}
|
||||||
fetcherRef.current()
|
fetcherRef.current()
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
@@ -138,7 +155,7 @@ export function useSessionResource<T>(
|
|||||||
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
||||||
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
|
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
|
||||||
};
|
};
|
||||||
}, [key, enabled]);
|
}, [key, enabled, revalidateEmpty]);
|
||||||
|
|
||||||
return { data, loading, pending, replace, invalidate };
|
return { data, loading, pending, replace, invalidate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SiteBranding } from '../api/types';
|
import type { SiteBranding } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
export const DEFAULT_BRANDING: SiteBranding = {
|
export const DEFAULT_BRANDING: SiteBranding = {
|
||||||
name: '姜十三论坛',
|
name: '姜十三论坛',
|
||||||
@@ -30,8 +31,8 @@ export function siteMetaDescription(brand: SiteBranding): string {
|
|||||||
return brand.slogan?.trim() ?? '';
|
return brand.slogan?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
|
|
||||||
function readBootBranding(): SiteBranding | null {
|
function readBootBranding(): SiteBranding | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
try {
|
try {
|
||||||
const boot = window.__J13_BRANDING__;
|
const boot = window.__J13_BRANDING__;
|
||||||
if (!boot || typeof boot !== 'object') return null;
|
if (!boot || typeof boot !== 'object') return null;
|
||||||
@@ -50,13 +51,20 @@ let cacheEpoch = 0;
|
|||||||
let preferCacheOnce = false;
|
let preferCacheOnce = false;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */
|
||||||
|
function currentBranding(): SiteBranding | null {
|
||||||
|
return getScope()?.branding ?? cached;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchBranding(): Promise<SiteBranding> {
|
function fetchBranding(): Promise<SiteBranding> {
|
||||||
if (preferCacheOnce && cached) {
|
if (preferCacheOnce && cached) {
|
||||||
preferCacheOnce = false;
|
preferCacheOnce = false;
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
|
// SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求;
|
||||||
|
// 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
|
|
||||||
inflight = api.siteBranding()
|
inflight = api.siteBranding()
|
||||||
.then(b => {
|
.then(b => {
|
||||||
cached = { ...DEFAULT_BRANDING, ...b };
|
cached = { ...DEFAULT_BRANDING, ...b };
|
||||||
@@ -96,13 +104,13 @@ function applyDocumentBrand(brand: SiteBranding) {
|
|||||||
|
|
||||||
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
||||||
export function getCachedSiteBranding(): SiteBranding {
|
export function getCachedSiteBranding(): SiteBranding {
|
||||||
return cached ?? DEFAULT_BRANDING;
|
return currentBranding() ?? DEFAULT_BRANDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取站点品牌配置(名称、Logo 等) */
|
/** 获取站点品牌配置(名称、Logo 等) */
|
||||||
export function useSiteBranding() {
|
export function useSiteBranding() {
|
||||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
const [branding, setBranding] = useState<SiteBranding>(currentBranding() ?? DEFAULT_BRANDING);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!currentBranding());
|
||||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,6 +139,7 @@ export function useSiteBranding() {
|
|||||||
|
|
||||||
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
|
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
|
||||||
export function refetchSiteBranding() {
|
export function refetchSiteBranding() {
|
||||||
|
cached = null;
|
||||||
inflight = null;
|
inflight = null;
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
@@ -140,6 +149,7 @@ export function refetchSiteBranding() {
|
|||||||
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
|
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
|
||||||
if (!opts?.force && cached) return cached;
|
if (!opts?.force && cached) return cached;
|
||||||
if (opts?.force) {
|
if (opts?.force) {
|
||||||
|
cached = null;
|
||||||
inflight = null;
|
inflight = null;
|
||||||
preferCacheOnce = false;
|
preferCacheOnce = false;
|
||||||
}
|
}
|
||||||
@@ -160,9 +170,15 @@ export function invalidateSiteBrandingCache() {
|
|||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
/** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */
|
||||||
export function seedSiteBrandingCache(brand: SiteBranding) {
|
export function seedSiteBrandingCache(brand: SiteBranding) {
|
||||||
cached = { ...DEFAULT_BRANDING, ...brand };
|
const merged: SiteBranding = { ...DEFAULT_BRANDING, ...brand };
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.branding = merged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cached = merged;
|
||||||
applyDocumentBrand(cached);
|
applyDocumentBrand(cached);
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SitePageSummary } from '../api/types';
|
import type { SitePageSummary } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||||
|
|
||||||
let cache: SitePageSummary[] | null = null;
|
let cache: SitePageSummary[] | null = null;
|
||||||
let pending: Promise<SitePageSummary[]> | null = null;
|
let pending: Promise<SitePageSummary[]> | null = null;
|
||||||
|
|
||||||
|
/** 当前生效的站点页摘要:SSR 读请求作用域,客户端读模块缓存 */
|
||||||
|
function currentPages(): SitePageSummary[] | null {
|
||||||
|
return getScope()?.pages ?? cache;
|
||||||
|
}
|
||||||
|
|
||||||
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
||||||
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
||||||
|
const cur = currentPages();
|
||||||
if (opts?.force) {
|
if (opts?.force) {
|
||||||
cache = null;
|
cache = null;
|
||||||
pending = null;
|
pending = null;
|
||||||
}
|
}
|
||||||
if (cache) return Promise.resolve(cache);
|
if (cur) return Promise.resolve(cur);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = api.pages()
|
pending = api.pages()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
@@ -31,7 +38,7 @@ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SiteP
|
|||||||
|
|
||||||
/** 已发布单页摘要(页脚/侧栏导航) */
|
/** 已发布单页摘要(页脚/侧栏导航) */
|
||||||
export function useSitePages() {
|
export function useSitePages() {
|
||||||
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
|
const [pages, setPages] = useState<SitePageSummary[]>(currentPages() ?? []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@@ -68,8 +75,14 @@ export function invalidateSitePagesCache() {
|
|||||||
cache = null;
|
cache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档 SSR:同步写入站点页摘要缓存 */
|
/** 文档 SSR:同步写入站点页摘要缓存(SSR 时只写请求作用域) */
|
||||||
export function seedSitePagesCache(pages: SitePageSummary[]) {
|
export function seedSitePagesCache(pages: SitePageSummary[]) {
|
||||||
cache = Array.isArray(pages) ? pages : [];
|
const list = Array.isArray(pages) ? pages : [];
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.pages = list;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cache = list;
|
||||||
pending = null;
|
pending = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export const useTheme = () => useContext(ThemeContext);
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体查询 Hook(hydration 安全)。
|
||||||
|
*
|
||||||
|
* SSR 永远输出桌面三栏布局,因此服务端与 hydrate 首帧统一返回 false,
|
||||||
|
* 保证首帧 React 树与服务端 DOM 完全同构(窄屏若首帧就读 matchMedia,
|
||||||
|
* 会与 SSR 桌面布局不一致,导致 React 丢弃整棵 SSR DOM 全量重建)。
|
||||||
|
* hydration 提交后的 layout effect 在首帧绘制前同步真实值并监听变化,
|
||||||
|
* 窄屏用户无闪烁地切换到移动布局。
|
||||||
|
*/
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
|
const [match, setMatch] = useState(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const m = window.matchMedia(query);
|
const m = window.matchMedia(query);
|
||||||
const fn = () => setMatch(m.matches);
|
const sync = () => setMatch(m.matches);
|
||||||
m.addEventListener('change', fn);
|
sync();
|
||||||
return () => m.removeEventListener('change', fn);
|
m.addEventListener('change', sync);
|
||||||
|
return () => m.removeEventListener('change', sync);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router-dom';
|
import { useBlocker } from 'react-router';
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
/** 是否存在未保存的修改 */
|
/** 是否存在未保存的修改 */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
|
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router';
|
||||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,13 +12,16 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||||
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types';
|
import type { Board, RecentComment, RecentUser, ForumStats, SitePage, TagCount, User, UserActivityStats, UserPublic } from '../api/types';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||||
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
|
import { getSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
|
import { postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
|
||||||
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
|
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
|
||||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||||
import RightPanel from '../components/RightPanel';
|
import RightPanel from '../components/RightPanel';
|
||||||
import BackToTop from '../components/BackToTop';
|
import BackToTop from '../components/BackToTop';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
||||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||||
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
@@ -44,44 +47,83 @@ import { userPath } from '../utils/userPath';
|
|||||||
import { parsePermalinkID } from '../utils/permalink';
|
import { parsePermalinkID } from '../utils/permalink';
|
||||||
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
||||||
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
|
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
|
||||||
import { getBootUnread } from '../utils/authBoot';
|
import { getBootUnread, peekAuthSeed } from '../utils/authBoot';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
useMonitorPageview();
|
useMonitorPageview();
|
||||||
const mqMobile = useMediaQuery('(max-width: 768px)');
|
// 响应式分支首帧恒为桌面(useMediaQuery hydration 安全),layout effect 绘制前同步真实值
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||||
/** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */
|
|
||||||
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
|
|
||||||
const isMobile = forceDesktop ? false : mqMobile;
|
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
||||||
|
|
||||||
|
/** hydrate 首帧提交后清除同构标志(shellReady/冷启动门闩在首帧渲染期读取) */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!forceDesktop) return;
|
|
||||||
endHomeHydrate();
|
endHomeHydrate();
|
||||||
startTransition(() => setForceDesktop(false));
|
}, []);
|
||||||
}, [forceDesktop]);
|
|
||||||
|
|
||||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||||
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
||||||
const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers());
|
const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers());
|
||||||
const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread());
|
const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread());
|
||||||
|
const [dmUnread, setDmUnread] = useState(0);
|
||||||
|
const [notifyUnread, setNotifyUnread] = useState(0);
|
||||||
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
||||||
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
||||||
const [postOutline, setPostOutline] = useState<{
|
// SSR / hydrate 首帧:详情页快照已在渲染前 seed(onRenderHtml / onRenderClient),
|
||||||
headings: PostHeading[];
|
// 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行,
|
||||||
scrollRoot: HTMLElement | null;
|
// 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。
|
||||||
title?: string;
|
const [postOutline, setPostOutline] = useState<PostOutlineState>(() => {
|
||||||
author?: User | null;
|
const openLinks = getCachedForumLimits()?.open_content_links_in_new_tab;
|
||||||
publishedAt?: string;
|
// 帖子详情:从 post 快照派生作者卡 + 目录
|
||||||
viewCount?: number;
|
const postM = loc.pathname.match(/^\/post\/(\d+)/);
|
||||||
} | null>(null);
|
if (postM && !/\/edit$/.test(loc.pathname)) {
|
||||||
|
const id = Number(postM[1]);
|
||||||
|
if (id) {
|
||||||
|
const snap = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(id));
|
||||||
|
if (snap?.post) {
|
||||||
|
const content = snap.post.content ?? '';
|
||||||
|
const rendered = content.trim()
|
||||||
|
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
headings: extractHeadingsFromHtml(rendered),
|
||||||
|
scrollRoot: null,
|
||||||
|
title: '文章目录',
|
||||||
|
author: snap.post.user ?? null,
|
||||||
|
publishedAt: snap.post.created_at,
|
||||||
|
viewCount: snap.post.view_count,
|
||||||
|
authorProfile: snap.authorProfile ?? null,
|
||||||
|
authorStats: snap.authorStats ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自定义单页:从 sitepage 快照派生目录(无作者卡)
|
||||||
|
const pageM = loc.pathname.match(/^\/page\/([^/]+?)(?:\.html?)?$/);
|
||||||
|
if (pageM) {
|
||||||
|
const slug = pageM[1];
|
||||||
|
const page = getSessionSnapshot<SitePage>(`sitepage:${slug}`);
|
||||||
|
if (page) {
|
||||||
|
const content = page.content ?? '';
|
||||||
|
const rendered = content.trim()
|
||||||
|
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
headings: extractHeadingsFromHtml(rendered),
|
||||||
|
scrollRoot: null,
|
||||||
|
title: '文章目录',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
const [asideOpen, setAsideOpen] = useState(false);
|
const [asideOpen, setAsideOpen] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||||
@@ -90,9 +132,9 @@ export default function MainLayout() {
|
|||||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||||
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
|
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
|
||||||
const asideEverLoaded = useRef(false);
|
const asideEverLoaded = useRef(false);
|
||||||
/** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */
|
/** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */
|
||||||
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
|
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating());
|
||||||
const coldBootDone = useRef(isCompose || wasColdBootEnsured());
|
const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating());
|
||||||
const bootGen = useRef(0);
|
const bootGen = useRef(0);
|
||||||
const [boardId, setBoardId] = useState(() => {
|
const [boardId, setBoardId] = useState(() => {
|
||||||
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
||||||
@@ -148,7 +190,10 @@ export default function MainLayout() {
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, [loc.pathname, loc.search]);
|
}, [loc.pathname, loc.search]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
const isArticleAside =
|
||||||
|
(/^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname))
|
||||||
|
|| /^\/page\//.test(loc.pathname);
|
||||||
|
if (!isArticleAside) setPostOutline(null);
|
||||||
}, [loc.pathname]);
|
}, [loc.pathname]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hideAside) setAsideOpen(false);
|
if (!hideAside) setAsideOpen(false);
|
||||||
@@ -209,7 +254,11 @@ export default function MainLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshBoards();
|
// SSR +data 已 seed boards/stats(或 SPA 冷启动已预热)时跳过首拉,避免重复请求;
|
||||||
|
// 后台操作 / 手动刷新经 boards-refresh 事件强制重拉
|
||||||
|
if (getCachedBoards().length === 0 || getCachedStats() === null) {
|
||||||
|
void refreshBoards();
|
||||||
|
}
|
||||||
const onRefresh = () => refreshBoards();
|
const onRefresh = () => refreshBoards();
|
||||||
window.addEventListener('boards-refresh', onRefresh);
|
window.addEventListener('boards-refresh', onRefresh);
|
||||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||||
@@ -306,15 +355,38 @@ export default function MainLayout() {
|
|||||||
const refreshUnreadMessages = useCallback(() => {
|
const refreshUnreadMessages = useCallback(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setUnreadMessages(0);
|
setUnreadMessages(0);
|
||||||
|
setDmUnread(0);
|
||||||
|
setNotifyUnread(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.messageUnreadCount()
|
api.messageUnreadCount()
|
||||||
.then((r) => setUnreadMessages(r.count || 0))
|
.then((r) => {
|
||||||
.catch(() => setUnreadMessages(0));
|
setUnreadMessages(r.count || 0);
|
||||||
|
setDmUnread(r.dm_count ?? 0);
|
||||||
|
setNotifyUnread(r.notify_count ?? 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setUnreadMessages(0);
|
||||||
|
setDmUnread(0);
|
||||||
|
setNotifyUnread(0);
|
||||||
|
});
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
/** 仅有通知未读时直达通知 Tab,避免先看到空私信列表 */
|
||||||
|
const openMessages = useCallback(() => {
|
||||||
|
const path = notifyUnread > 0 && dmUnread === 0 ? '/messages?tab=notify' : '/messages';
|
||||||
|
void transitionTo(nav, path);
|
||||||
|
}, [nav, notifyUnread, dmUnread]);
|
||||||
|
|
||||||
|
// 仅首屏跳过一次:SSR/冷启动种子已带未读数(state 从 getBootUnread 初始化),不重复请求;
|
||||||
|
// 之后 user 变化(登录/退出)、事件刷新与 60s 轮询照常
|
||||||
|
const unreadImmediateSkipped = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshUnreadMessages();
|
if (!unreadImmediateSkipped.current && peekAuthSeed().hasSeed) {
|
||||||
|
unreadImmediateSkipped.current = true;
|
||||||
|
} else {
|
||||||
|
refreshUnreadMessages();
|
||||||
|
}
|
||||||
const onRefresh = () => refreshUnreadMessages();
|
const onRefresh = () => refreshUnreadMessages();
|
||||||
window.addEventListener('messages-unread-refresh', onRefresh);
|
window.addEventListener('messages-unread-refresh', onRefresh);
|
||||||
const timer = window.setInterval(refreshUnreadMessages, 60_000);
|
const timer = window.setInterval(refreshUnreadMessages, 60_000);
|
||||||
@@ -365,6 +437,13 @@ export default function MainLayout() {
|
|||||||
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompose || !showTagCloud) return;
|
if (isCompose || !showTagCloud) return;
|
||||||
|
// SSR +data / 冷启动预热已 seed 侧栏数据(含空数组)时首屏直接复用,不重复请求;
|
||||||
|
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setTags(getCachedTags());
|
||||||
|
setTagsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// 有 session 缓存则静默刷新(软刷新不卸空白)
|
// 有 session 缓存则静默刷新(软刷新不卸空白)
|
||||||
if (getCachedTags().length === 0) setTagsLoading(true);
|
if (getCachedTags().length === 0) setTagsLoading(true);
|
||||||
@@ -385,6 +464,14 @@ export default function MainLayout() {
|
|||||||
const needRecentComments = needAsideData && showRecentComments;
|
const needRecentComments = needAsideData && showRecentComments;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needRecentComments) return;
|
if (!needRecentComments) return;
|
||||||
|
// SSR +data / 冷启动预热已 seed:首屏同步缓存进 state,不重复请求;
|
||||||
|
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setRecentComments(getCachedRecentComments());
|
||||||
|
asideEverLoaded.current = true;
|
||||||
|
setAsideLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
||||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||||
@@ -411,6 +498,12 @@ export default function MainLayout() {
|
|||||||
const needRecentUsers = needAsideData && showRecentUsers;
|
const needRecentUsers = needAsideData && showRecentUsers;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needRecentUsers) return;
|
if (!needRecentUsers) return;
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setRecentUsers(getCachedRecentUsers());
|
||||||
|
asideEverLoaded.current = true;
|
||||||
|
setAsideLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||||
setAsideLoading(true);
|
setAsideLoading(true);
|
||||||
@@ -477,14 +570,11 @@ export default function MainLayout() {
|
|||||||
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
|
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
|
||||||
|
|
||||||
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
|
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
|
||||||
const setPostOutlineSafe = useCallback((outline: {
|
const isSitePage = /^\/page\//.test(loc.pathname);
|
||||||
headings: PostHeading[];
|
const isArticleAside = isPostDetail || isSitePage;
|
||||||
scrollRoot: HTMLElement | null;
|
// 帖子有作者 →「作者与目录」;单页仅目录
|
||||||
title?: string;
|
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
|
||||||
author?: User | null;
|
const setPostOutlineSafe = useCallback((outline: PostOutlineState) => {
|
||||||
publishedAt?: string;
|
|
||||||
viewCount?: number;
|
|
||||||
} | null) => {
|
|
||||||
setPostOutline(outline);
|
setPostOutline(outline);
|
||||||
}, []);
|
}, []);
|
||||||
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
||||||
@@ -516,7 +606,8 @@ export default function MainLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('app-shell', forceDesktop && 'ssr-home')}>
|
// Feed / 帖子详情用文档滚动:F5 / 前进后退的滚动位置由浏览器原生历史恢复
|
||||||
|
<div className={cn('app-shell', !isCompose && (isFeedHome || isPostDetail) && 'app-shell--doc-scroll')}>
|
||||||
<div className="app-frame">
|
<div className="app-frame">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
@@ -632,10 +723,10 @@ export default function MainLayout() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="header-icon-btn"
|
className="header-icon-btn"
|
||||||
onClick={openAside}
|
onClick={openAside}
|
||||||
aria-label={isPostDetail ? '打开作者与目录' : '打开社区动态'}
|
aria-label={isArticleAside ? `打开${articleAsideLabel}` : '打开社区动态'}
|
||||||
aria-expanded={asideOpen}
|
aria-expanded={asideOpen}
|
||||||
aria-controls="aside-drawer"
|
aria-controls="aside-drawer"
|
||||||
title={isPostDetail ? '作者与目录' : '社区动态'}
|
title={isArticleAside ? articleAsideLabel : '社区动态'}
|
||||||
>
|
>
|
||||||
<PanelRight size={18} aria-hidden />
|
<PanelRight size={18} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
@@ -660,7 +751,7 @@ export default function MainLayout() {
|
|||||||
className="header-icon-btn header-msg-btn"
|
className="header-icon-btn header-msg-btn"
|
||||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
||||||
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
||||||
onClick={() => void transitionTo(nav, '/messages')}
|
onClick={openMessages}
|
||||||
>
|
>
|
||||||
<Mail size={18} aria-hidden />
|
<Mail size={18} aria-hidden />
|
||||||
{unreadMessages > 0 && (
|
{unreadMessages > 0 && (
|
||||||
@@ -684,7 +775,7 @@ export default function MainLayout() {
|
|||||||
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
|
||||||
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => void transitionTo(nav, '/messages')}>
|
<DropdownMenuItem onClick={openMessages}>
|
||||||
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}>我的收藏</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}>我的收藏</DropdownMenuItem>
|
||||||
@@ -795,11 +886,10 @@ export default function MainLayout() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shellReady ? (
|
{/* 不在此处包 Suspense:React 19 Fizz 会把 Suspense 边界内大体积/含 <img>
|
||||||
<Suspense fallback={null}>
|
的同步内容 outlining 为 <div hidden> 分块(禁 JS 爬虫不可见)。
|
||||||
<Outlet context={layoutCtx} />
|
懒加载路由的 Suspense 已在 AppRoutes 各路由 element 上就近声明。 */}
|
||||||
</Suspense>
|
{shellReady ? <Outlet context={layoutCtx} /> : null}
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
@@ -815,13 +905,15 @@ export default function MainLayout() {
|
|||||||
asideWidgets={asideWidgets}
|
asideWidgets={asideWidgets}
|
||||||
onPostClick={openPost}
|
onPostClick={openPost}
|
||||||
|
|
||||||
postDetail={isPostDetail ? {
|
postDetail={isArticleAside ? {
|
||||||
author: postOutline?.author ?? null,
|
author: postOutline?.author ?? null,
|
||||||
publishedAt: postOutline?.publishedAt,
|
publishedAt: postOutline?.publishedAt,
|
||||||
viewCount: postOutline?.viewCount,
|
viewCount: postOutline?.viewCount,
|
||||||
headings: postOutline?.headings ?? [],
|
headings: postOutline?.headings ?? [],
|
||||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||||
outlineTitle: postOutline?.title,
|
outlineTitle: postOutline?.title,
|
||||||
|
authorProfile: postOutline?.authorProfile ?? null,
|
||||||
|
authorStats: postOutline?.authorStats ?? null,
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -879,7 +971,7 @@ export default function MainLayout() {
|
|||||||
onClick={() => { closeSidebar(); openAside(); }}
|
onClick={() => { closeSidebar(); openAside(); }}
|
||||||
>
|
>
|
||||||
<PanelRight size={16} aria-hidden />
|
<PanelRight size={16} aria-hidden />
|
||||||
{isPostDetail ? '作者与目录' : '社区动态'}
|
{isArticleAside ? articleAsideLabel : '社区动态'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -910,10 +1002,10 @@ export default function MainLayout() {
|
|||||||
className="aside-drawer"
|
className="aside-drawer"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={isPostDetail ? '作者与目录' : '社区动态'}
|
aria-label={isArticleAside ? articleAsideLabel : '社区动态'}
|
||||||
>
|
>
|
||||||
<div className="aside-drawer-head">
|
<div className="aside-drawer-head">
|
||||||
<span>{isPostDetail ? '作者与目录' : '社区动态'}</span>
|
<span>{isArticleAside ? articleAsideLabel : '社区动态'}</span>
|
||||||
<button
|
<button
|
||||||
ref={asideCloseRef}
|
ref={asideCloseRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -934,13 +1026,15 @@ export default function MainLayout() {
|
|||||||
loading={asideLoading}
|
loading={asideLoading}
|
||||||
asideWidgets={asideWidgets}
|
asideWidgets={asideWidgets}
|
||||||
onPostClick={openPost}
|
onPostClick={openPost}
|
||||||
postDetail={isPostDetail ? {
|
postDetail={isArticleAside ? {
|
||||||
author: postOutline?.author ?? null,
|
author: postOutline?.author ?? null,
|
||||||
publishedAt: postOutline?.publishedAt,
|
publishedAt: postOutline?.publishedAt,
|
||||||
viewCount: postOutline?.viewCount,
|
viewCount: postOutline?.viewCount,
|
||||||
headings: postOutline?.headings ?? [],
|
headings: postOutline?.headings ?? [],
|
||||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||||
outlineTitle: postOutline?.title,
|
outlineTitle: postOutline?.title,
|
||||||
|
authorProfile: postOutline?.authorProfile ?? null,
|
||||||
|
authorStats: postOutline?.authorStats ?? null,
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -953,6 +1047,19 @@ export default function MainLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情页/单页上报到右栏的文章目录与作者信息 */
|
||||||
|
export type PostOutlineState = {
|
||||||
|
headings: PostHeading[];
|
||||||
|
scrollRoot: HTMLElement | null;
|
||||||
|
title?: string;
|
||||||
|
author?: User | null;
|
||||||
|
publishedAt?: string;
|
||||||
|
viewCount?: number;
|
||||||
|
/** SSR/预热快照携带的作者主页资料与统计(作者卡首帧直接渲染,缺省由卡片自行拉取) */
|
||||||
|
authorProfile?: UserPublic | null;
|
||||||
|
authorStats?: UserActivityStats | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export type LayoutCtx = {
|
export type LayoutCtx = {
|
||||||
boardId: number;
|
boardId: number;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -963,12 +1070,5 @@ export type LayoutCtx = {
|
|||||||
refreshBoards: () => void;
|
refreshBoards: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
/** 详情页上报作者与目录,供右侧栏展示 */
|
/** 详情页上报作者与目录,供右侧栏展示 */
|
||||||
setPostOutline: (outline: {
|
setPostOutline: (outline: PostOutlineState) => void;
|
||||||
headings: PostHeading[];
|
|
||||||
scrollRoot: HTMLElement | null;
|
|
||||||
title?: string;
|
|
||||||
author?: User | null;
|
|
||||||
publishedAt?: string;
|
|
||||||
viewCount?: number;
|
|
||||||
} | null) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
||||||
import { applyTheme, getStoredTheme } from './utils/theme';
|
|
||||||
import App from './App';
|
|
||||||
import { consumeHomeBoot } from './utils/homeBoot';
|
|
||||||
import { beginHomeHydrate } from './utils/homeHydrate';
|
|
||||||
import { reloadForStaleChunk } from './utils/chunkLoad';
|
|
||||||
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
|
|
||||||
|
|
||||||
applyTheme(getStoredTheme());
|
|
||||||
consumeHomeBoot();
|
|
||||||
|
|
||||||
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
|
|
||||||
window.addEventListener('vite:preloadError', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
reloadForStaleChunk();
|
|
||||||
});
|
|
||||||
|
|
||||||
function hasSSRHome(): boolean {
|
|
||||||
return !!document.querySelector('#root .ssr-home');
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderApp() {
|
|
||||||
return (
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountCreateRoot(el: HTMLElement) {
|
|
||||||
createRoot(el).render(renderApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 有 SSR 正文:hydrate 接管同一 DOM;失败则回退 createRoot */
|
|
||||||
function mountHydrateHome(el: HTMLElement) {
|
|
||||||
beginHomeHydrate();
|
|
||||||
try {
|
|
||||||
hydrateRoot(el, renderApp());
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[j13] hydrateRoot 失败,回退 createRoot', err);
|
|
||||||
el.innerHTML = '';
|
|
||||||
mountCreateRoot(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot() {
|
|
||||||
const path = `${window.location.pathname}${window.location.search}`;
|
|
||||||
const ssr = hasSSRHome();
|
|
||||||
|
|
||||||
await waitForStylesheets();
|
|
||||||
try {
|
|
||||||
if (document.fonts?.ready) await document.fonts.ready;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMainLayoutPath(window.location.pathname)) {
|
|
||||||
await ensureColdBootReady(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (ssr) {
|
|
||||||
mountHydrateHome(root);
|
|
||||||
} else {
|
|
||||||
mountCreateRoot(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void boot();
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, type Resolver } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Plus, FolderKanban } from 'lucide-react';
|
import { Plus, FolderKanban } from 'lucide-react';
|
||||||
@@ -52,8 +52,9 @@ export default function BoardsManagePage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [reordering, setReordering] = useState(false);
|
const [reordering, setReordering] = useState(false);
|
||||||
|
|
||||||
|
// z.coerce 的 input 类型为 unknown,这里显式断言对齐 output 类型
|
||||||
const form = useForm<BoardFormValues>({
|
const form = useForm<BoardFormValues>({
|
||||||
resolver: zodResolver(boardSchema),
|
resolver: zodResolver(boardSchema) as Resolver<BoardFormValues>,
|
||||||
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
|
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { ArrowLeft, Star } from 'lucide-react';
|
import { ArrowLeft, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useOutletContext,
|
useOutletContext,
|
||||||
@@ -6,18 +6,23 @@ import {
|
|||||||
useLocation,
|
useLocation,
|
||||||
useParams,
|
useParams,
|
||||||
useNavigationType,
|
useNavigationType,
|
||||||
} from 'react-router-dom';
|
} from 'react-router';
|
||||||
|
import { Inbox, SearchX } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
import VirtualPostList from '../components/VirtualPostList';
|
import PostListItem from '../components/PostListItem';
|
||||||
import StaticFeedList from '../components/StaticFeedList';
|
import FeedPagination from '../components/FeedPagination';
|
||||||
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import FeedHeader from '../components/FeedHeader';
|
import FeedHeader from '../components/FeedHeader';
|
||||||
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||||
import {
|
import {
|
||||||
navigateFeed,
|
navigateFeed,
|
||||||
FEED_RESET_EVENT,
|
FEED_RESET_EVENT,
|
||||||
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
|||||||
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
import { startTransition, doneTransition } from '../utils/spaTransition';
|
import { startTransition, doneTransition } from '../utils/spaTransition';
|
||||||
import { isHomeHydrating } from '../utils/homeHydrate';
|
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||||
@@ -49,7 +53,6 @@ type FeedHydrate = {
|
|||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
page: number;
|
page: number;
|
||||||
scrollTop: number;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,11 +72,10 @@ function readHydrate(
|
|||||||
posts: cached.posts,
|
posts: cached.posts,
|
||||||
postTotal: cached.postTotal,
|
postTotal: cached.postTotal,
|
||||||
page: cached.page,
|
page: cached.page,
|
||||||
scrollTop: cached.scrollTop,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
|
return { posts: [], postTotal: 0, page: 1, loading: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -83,6 +85,7 @@ export default function HomePage() {
|
|||||||
const { id: boardRouteId } = useParams();
|
const { id: boardRouteId } = useParams();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const ctx = useOutletContext<LayoutCtx>();
|
const ctx = useOutletContext<LayoutCtx>();
|
||||||
|
const { user } = useAuth();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const { limits, loading: limitsLoading } = useForumLimits();
|
const { limits, loading: limitsLoading } = useForumLimits();
|
||||||
const postSearch = usePostSearch(limits);
|
const postSearch = usePostSearch(limits);
|
||||||
@@ -187,25 +190,14 @@ export default function HomePage() {
|
|||||||
titleOnly,
|
titleOnly,
|
||||||
});
|
});
|
||||||
const [listPending, setListPending] = useState(false);
|
const [listPending, setListPending] = useState(false);
|
||||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
/** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
|
||||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
|
||||||
);
|
|
||||||
const [listResetKey, setListResetKey] = useState(0);
|
const [listResetKey, setListResetKey] = useState(0);
|
||||||
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
|
|
||||||
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (useVirtualList) return;
|
|
||||||
reactStartTransition(() => setUseVirtualList(true));
|
|
||||||
}, [useVirtualList]);
|
|
||||||
|
|
||||||
const scrollTopRef = useRef(initial.scrollTop);
|
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const pageRef = useRef(initial.page);
|
const pageRef = useRef(initial.page);
|
||||||
const cacheKeyRef = useRef(cacheKey);
|
const cacheKeyRef = useRef(cacheKey);
|
||||||
const viewKeyRef = useRef(view.cacheKey);
|
const viewKeyRef = useRef(view.cacheKey);
|
||||||
const postsRef = useRef(posts);
|
const postsRef = useRef(posts);
|
||||||
const scrollRafRef = useRef(0);
|
|
||||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||||
const hydratedKeyRef = useRef<string | null>(null);
|
const hydratedKeyRef = useRef<string | null>(null);
|
||||||
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
||||||
@@ -223,8 +215,6 @@ export default function HomePage() {
|
|||||||
setPostTotal(next.postTotal);
|
setPostTotal(next.postTotal);
|
||||||
setPage(next.page);
|
setPage(next.page);
|
||||||
pageRef.current = next.page;
|
pageRef.current = next.page;
|
||||||
scrollTopRef.current = next.scrollTop;
|
|
||||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
|
||||||
setLoading(next.loading);
|
setLoading(next.loading);
|
||||||
setListPending(false);
|
setListPending(false);
|
||||||
setView(meta);
|
setView(meta);
|
||||||
@@ -254,10 +244,10 @@ export default function HomePage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
const showPagination = totalPages > 1 && posts.length > 0;
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
|
||||||
|
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||||
|
|
||||||
const resetFeedView = useCallback(() => {
|
const resetFeedView = useCallback(() => {
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -266,12 +256,12 @@ export default function HomePage() {
|
|||||||
resetFeedView();
|
resetFeedView();
|
||||||
}, [resetFeedView]);
|
}, [resetFeedView]);
|
||||||
|
|
||||||
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
|
/** 把当前列表写入 Zustand */
|
||||||
const persistFeed = useCallback((
|
const persistFeed = useCallback((
|
||||||
nextPosts: PostItem[],
|
nextPosts: PostItem[],
|
||||||
nextTotal: number,
|
nextTotal: number,
|
||||||
nextPage: number,
|
nextPage: number,
|
||||||
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
|
opts?: { touchFetchTime?: boolean; key?: string },
|
||||||
) => {
|
) => {
|
||||||
const key = opts?.key ?? cacheKeyRef.current;
|
const key = opts?.key ?? cacheKeyRef.current;
|
||||||
const prev = getHomeStoreState().getFeed(key);
|
const prev = getHomeStoreState().getFeed(key);
|
||||||
@@ -279,7 +269,6 @@ export default function HomePage() {
|
|||||||
posts: nextPosts,
|
posts: nextPosts,
|
||||||
postTotal: nextTotal,
|
postTotal: nextTotal,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
|
|
||||||
lastFetchTime: opts?.touchFetchTime === false
|
lastFetchTime: opts?.touchFetchTime === false
|
||||||
? (prev?.lastFetchTime ?? Date.now())
|
? (prev?.lastFetchTime ?? Date.now())
|
||||||
: Date.now(),
|
: Date.now(),
|
||||||
@@ -309,10 +298,7 @@ export default function HomePage() {
|
|||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
const total = data.total ?? 0;
|
const total = data.total ?? 0;
|
||||||
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
||||||
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
|
|
||||||
if (jumpTop) {
|
if (jumpTop) {
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}
|
}
|
||||||
setPosts(batch);
|
setPosts(batch);
|
||||||
@@ -320,7 +306,7 @@ export default function HomePage() {
|
|||||||
setPage(p);
|
setPage(p);
|
||||||
pageRef.current = p;
|
pageRef.current = p;
|
||||||
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
|
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
|
persistFeed(batch, total, p, { touchFetchTime: true, key: requestKey });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (seq !== fetchSeqRef.current) return;
|
if (seq !== fetchSeqRef.current) return;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
@@ -352,8 +338,6 @@ export default function HomePage() {
|
|||||||
}, [fetchPage, postTotal, pageSize]);
|
}, [fetchPage, postTotal, pageSize]);
|
||||||
|
|
||||||
const handleSelectPost = useCallback((id: number) => {
|
const handleSelectPost = useCallback((id: number) => {
|
||||||
// 离开前再写一次滚动,确保详情页返回可还原
|
|
||||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
|
||||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||||
}, [nav, limits.open_posts_in_new_tab]);
|
}, [nav, limits.open_posts_in_new_tab]);
|
||||||
|
|
||||||
@@ -399,7 +383,6 @@ export default function HomePage() {
|
|||||||
posts: cached.posts,
|
posts: cached.posts,
|
||||||
postTotal: cached.postTotal,
|
postTotal: cached.postTotal,
|
||||||
page: cached.page,
|
page: cached.page,
|
||||||
scrollTop: cached.scrollTop,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
} else {
|
} else {
|
||||||
@@ -415,10 +398,6 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hydratedKeyRef.current = cacheKey;
|
hydratedKeyRef.current = cacheKey;
|
||||||
if (postsRef.current.length === 0) {
|
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
}
|
|
||||||
loadFirst();
|
loadFirst();
|
||||||
}, [
|
}, [
|
||||||
limitsLoading,
|
limitsLoading,
|
||||||
@@ -466,7 +445,6 @@ export default function HomePage() {
|
|||||||
posts: warm.posts,
|
posts: warm.posts,
|
||||||
postTotal: warm.postTotal,
|
postTotal: warm.postTotal,
|
||||||
page: warm.page,
|
page: warm.page,
|
||||||
scrollTop: 0,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
setListResetKey((k) => k + 1);
|
setListResetKey((k) => k + 1);
|
||||||
@@ -495,20 +473,21 @@ export default function HomePage() {
|
|||||||
sort, boardId, keyword, tag, author, titleOnly,
|
sort, boardId, keyword, tag, author, titleOnly,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 卸载时取消未执行的 scroll rAF
|
// 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
|
||||||
useEffect(() => () => {
|
// - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
|
||||||
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
|
// - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
|
||||||
}, []);
|
// 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (listResetKey <= 0) return;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [listResetKey]);
|
||||||
|
|
||||||
const handleScrollTopChange = useCallback((top: number) => {
|
// PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
|
||||||
scrollTopRef.current = top;
|
// POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
|
||||||
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
|
useEffect(() => {
|
||||||
if (scrollRafRef.current) return;
|
if (navType === 'POP') return;
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
window.scrollTo(0, 0);
|
||||||
scrollRafRef.current = 0;
|
}, [cacheKey, navType]);
|
||||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSortChange = (next: FeedSort) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
||||||
@@ -527,6 +506,32 @@ export default function HomePage() {
|
|||||||
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
||||||
const isSearchActive = !!(view.keyword || view.author);
|
const isSearchActive = !!(view.keyword || view.author);
|
||||||
|
|
||||||
|
// 空态(加载中且无列表时已在上方 return null,此处只处理真空态)
|
||||||
|
const viewBoardName = ctx?.boards?.find(b => b.id === view.boardId)?.name || '';
|
||||||
|
const noBoards = !ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0;
|
||||||
|
const isSearchEmpty = posts.length === 0 && !loading
|
||||||
|
&& (isSearchActive || !!view.tag || !!(view.keyword || '').trim());
|
||||||
|
const composeTarget = view.boardId > 0 ? `/compose?board=${view.boardId}` : '/compose';
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const searchSummaryParts: string[] = [];
|
||||||
|
const kw = (view.keyword || view.tag || '').trim();
|
||||||
|
if (kw) searchSummaryParts.push(`关键词「${kw}」`);
|
||||||
|
if (view.author?.trim()) searchSummaryParts.push(`作者「${view.author.trim()}」`);
|
||||||
|
if (view.titleOnly) searchSummaryParts.push('仅标题');
|
||||||
|
if (searchFilters.scopeBoardId) {
|
||||||
|
const bn = ctx?.boards?.find(b => b.id === searchFilters.scopeBoardId)?.name || '';
|
||||||
|
if (bn) searchSummaryParts.push(`板块「${bn}」`);
|
||||||
|
}
|
||||||
|
const emptyHint = noBoards
|
||||||
|
? (isAdmin
|
||||||
|
? '网站还没有任何板块。你可以创建第一个板块,给帖子们一个归属。'
|
||||||
|
: '网站刚刚创建,管理员还没有添加板块。稍后再来看看吧。')
|
||||||
|
: isSearchEmpty
|
||||||
|
? `${searchSummaryParts.join(' · ')}${searchSummaryParts.length ? '。' : ''}换个关键词试试,或清除当前筛选条件。`
|
||||||
|
: (view.boardId > 0
|
||||||
|
? `板块「${viewBoardName}」还没有帖子。看看其他板块,或发表这个板块的第一帖。`
|
||||||
|
: '成为第一个发帖的人,开始讨论吧。');
|
||||||
|
|
||||||
if (isInvalidBoardRoute || isMissingBoard) {
|
if (isInvalidBoardRoute || isMissingBoard) {
|
||||||
return (
|
return (
|
||||||
<NotFoundPage
|
<NotFoundPage
|
||||||
@@ -546,25 +551,13 @@ export default function HomePage() {
|
|||||||
<div className="feed-panel">
|
<div className="feed-panel">
|
||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<div className="feed-top__bar">
|
||||||
{!useVirtualList ? (
|
<FeedHeader
|
||||||
(view.keyword || view.tag || view.author) ? (
|
keyword={view.keyword}
|
||||||
<h1 className="feed-header-title">
|
tag={view.tag}
|
||||||
{view.tag
|
author={view.author}
|
||||||
? `#${view.tag}`
|
postTotal={postTotal}
|
||||||
: view.keyword
|
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||||
? `搜索:${view.keyword}`
|
/>
|
||||||
: `作者:${view.author}`}
|
|
||||||
</h1>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<FeedHeader
|
|
||||||
keyword={view.keyword}
|
|
||||||
tag={view.tag}
|
|
||||||
author={view.author}
|
|
||||||
postTotal={postTotal}
|
|
||||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showSortBar && (
|
{showSortBar && (
|
||||||
<FeedSortBar
|
<FeedSortBar
|
||||||
value={view.sort}
|
value={view.sort}
|
||||||
@@ -583,36 +576,79 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!useVirtualList ? (
|
{/*
|
||||||
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
|
列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
|
||||||
) : (
|
不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
|
||||||
<VirtualPostList
|
也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
|
||||||
posts={posts}
|
*/}
|
||||||
sort={view.sort}
|
<div className="post-list-scroll post-list-scroll--ssr">
|
||||||
loading={listPending ? false : (loading || limitsLoading)}
|
{posts.length === 0 ? (
|
||||||
hasMore={hasMore}
|
<div className="empty-feed" role="status">
|
||||||
showPagination={showPagination}
|
{isSearchEmpty
|
||||||
page={page}
|
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
totalPages={totalPages}
|
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
|
||||||
postTotal={postTotal}
|
<p>{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
|
||||||
onPageChange={goToPage}
|
<p className="empty-feed-hint">{emptyHint}</p>
|
||||||
onSelect={handleSelectPost}
|
<div className="empty-feed-actions">
|
||||||
restoreScrollTop={restoreScrollTop}
|
{noBoards ? (
|
||||||
resetScrollKey={listResetKey}
|
isAdmin ? (
|
||||||
onScrollTopChange={handleScrollTopChange}
|
<Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
) : (
|
||||||
keyword={view.keyword || view.tag || view.author}
|
<Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
|
||||||
isSearchMode={!!(view.keyword || view.author)}
|
)
|
||||||
searchKeyword={view.keyword}
|
) : isSearchEmpty ? (
|
||||||
searchAuthor={view.author}
|
<>
|
||||||
searchTitleOnly={view.titleOnly}
|
<Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
|
||||||
searchScopeBoardId={searchFilters.scopeBoardId}
|
<Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
|
||||||
onClearSearch={postSearch.clearSearch}
|
<Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
|
||||||
boardId={view.boardId}
|
</>
|
||||||
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
|
) : view.boardId > 0 ? (
|
||||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
<>
|
||||||
/>
|
<Button variant="ghost" onClick={() => nav('/')}>看看其他板块</Button>
|
||||||
)}
|
{user ? (
|
||||||
|
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : user ? (
|
||||||
|
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="content-surface content-surface--ssr">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostListItem
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
sort={view.sort}
|
||||||
|
boardId={view.boardId}
|
||||||
|
onSelect={handleSelectPost}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showPagination && (
|
||||||
|
<div className="feed-list-footer feed-list-footer--pagination">
|
||||||
|
<FeedPagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
postTotal={postTotal}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={goToPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showEnd && (
|
||||||
|
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||||
|
)}
|
||||||
|
<InFlowSiteFooter />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } 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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
import { useNavigate, Link, useSearchParams } from 'react-router';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user