Compare commits
10 Commits
711cd8b759
...
2392768868
| Author | SHA1 | Date | |
|---|---|---|---|
| 2392768868 | |||
| 3702e3d1f2 | |||
| 230006a905 | |||
| ba39376a0e | |||
| 1ce0f123fa | |||
| c99238c1e7 | |||
| e71c77b5ca | |||
| 27132e89c2 | |||
| 86a317c862 | |||
| 52d328ffc3 |
@@ -14,7 +14,6 @@ tmp-cookie.txt
|
||||
dist/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
embed_static/static/spa/
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,5 +27,3 @@ tmp-cookie.txt
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
||||
frontend/tmp-lucide-paths.json
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@@ -1,15 +1,20 @@
|
||||
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像
|
||||
# 姜十三论坛 — 多阶段构建:Node 前端(Vike SSR)+ Go API,双服务共用镜像
|
||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||
#
|
||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||
# 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 VERSION=dev
|
||||
|
||||
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)────────────────────
|
||||
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend
|
||||
# ── Stage 1: 前端构建(Vike → dist/client 静态资源 + dist/server SSR 入口)──
|
||||
FROM ${IMAGE_PREFIX}node:24-bookworm-slim AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
@@ -17,7 +22,7 @@ RUN npm config set registry https://registry.npmmirror.com \
|
||||
COPY frontend/ ./
|
||||
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
|
||||
ARG VERSION
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
@@ -25,31 +30,31 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/jiang13 ./cmd/jiang13
|
||||
|
||||
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
|
||||
FROM ${IMAGE_PREFIX}alpine:3.21
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata wget su-exec \
|
||||
&& adduser -D -u 1000 -h /app jiang13
|
||||
# ── Stage 3: 运行镜像(Node 运行时承载 SSR 渲染与静态资源,附带 Go API 二进制)
|
||||
FROM ${IMAGE_PREFIX}node:24-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gosu ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV TZ=Asia/Shanghai \
|
||||
NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jiang13 /app/jiang13
|
||||
COPY --from=frontend /src/frontend /app/frontend
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chown jiang13:jiang13 /app/jiang13 \
|
||||
&& chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
RUN chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 gosu 降权为 uid 1000(node 用户)
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
EXPOSE 3000 3001
|
||||
|
||||
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"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||
|
||||
46
Makefile
46
Makefile
@@ -12,39 +12,40 @@ REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||
GO := go
|
||||
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
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm install && npm run build
|
||||
## 前端构建(Vike:产出 frontend/dist/client 静态资源 + dist/server SSR 入口)
|
||||
web-build:
|
||||
cd frontend && (test -d node_modules || npm install) && npm run build
|
||||
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||
build: frontend-build
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO;Go 仅含 API,前端由 Node 渲染)
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||
|
||||
## Windows amd64(先打包前端再 embed)
|
||||
build-windows: frontend-build
|
||||
## Windows amd64
|
||||
build-windows:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
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"
|
||||
|
||||
## Linux amd64(先打包前端再 embed)
|
||||
build-linux: frontend-build
|
||||
## Linux amd64
|
||||
build-linux:
|
||||
@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)
|
||||
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||
|
||||
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||
build-darwin: frontend-build
|
||||
## macOS arm64 (Apple Silicon)
|
||||
build-darwin:
|
||||
@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)
|
||||
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||
|
||||
## 跨平台全量编译(frontend-build 只跑一次)
|
||||
build-all: frontend-build
|
||||
## 跨平台全量编译
|
||||
build-all:
|
||||
@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=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:
|
||||
$(GO) mod tidy
|
||||
|
||||
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致)
|
||||
## 本地运行(仅 Go API :3000,无前端页面;数据目录与 dist 二进制一致)
|
||||
run:
|
||||
@mkdir -p $(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:
|
||||
@echo "前端热更新: http://localhost:5173"
|
||||
@echo "后端 API : http://localhost:3000"
|
||||
@@ -79,7 +87,7 @@ clean:
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||
|
||||
## Docker Compose 启动
|
||||
## Docker Compose 启动(web :3000 + api :3001 双容器)
|
||||
compose-up:
|
||||
docker compose up -d --build
|
||||
|
||||
@@ -89,12 +97,14 @@ compose-down:
|
||||
|
||||
help:
|
||||
@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-linux - 编译 Linux"
|
||||
@echo " make build-darwin - 编译 macOS"
|
||||
@echo " make build-all - 编译全部平台"
|
||||
@echo " make run - 仅启动后端(:3000)"
|
||||
@echo " make run - 仅启动 Go API(:3000)"
|
||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||
@echo " make docker - 构建 Docker 镜像"
|
||||
@echo " make compose-up - Docker Compose 启动"
|
||||
|
||||
140
README.md
140
README.md
@@ -13,7 +13,7 @@
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](frontend/package.json)
|
||||
[](#)
|
||||
|
||||
[在线演示](https://bbs.iioio.com/) ·
|
||||
@@ -114,11 +114,12 @@
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||
- **双服务架构** — Node(:3000 对外:SSR + 静态资源 + API 反代)+ Go(:3001 内部:纯 API),职责单一、可独立扩缩
|
||||
- **服务端渲染** — 首页 / 板块 / 帖子详情由 Vike SSR 真渲染,SEO 友好、首屏直出;其余页面 SPA 接管
|
||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译 Go API
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||
- **Docker 双容器** — 同一镜像两种角色,`docker compose up` 一键起
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +127,7 @@
|
||||
|
||||
### 1. 编译
|
||||
|
||||
**Windows(推荐):**
|
||||
Go API 二进制(不含前端,Windows):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
@@ -142,9 +143,21 @@ make build
|
||||
|
||||
**手动分步(全平台):**
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
前端构建(Vike,产出 `frontend/dist/client` + `dist/server`):
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
跨平台编译:
|
||||
@@ -180,31 +193,38 @@ make compose-up
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
同一镜像两种角色,建议直接用 `docker-compose.yml`(已含完整配置):
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**数据持久化:** 容器内 `/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 /你的数据目录`
|
||||
|
||||
**可选环境变量(容器编排):**
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||
| 变量 | 容器 | 说明 |
|
||||
|------|------|------|
|
||||
| `GO_API_URL` | web | Go API 地址(默认 `http://127.0.0.1:3001`,Compose 中为 `http://api:3001`) |
|
||||
| `PORT` | web | Node 渲染服务器监听端口(默认 `3000`) |
|
||||
| `JIANG13_HTTP_PORT` | api | HTTP 端口(默认 `3000`,Compose 中为 `3001`) |
|
||||
| `JIANG13_DATA` | api | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_JWT_SECRET` | api | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||
| `JIANG13_CONFIG` | api | 配置文件路径 |
|
||||
| `JIANG13_WORK_PATH` | api | 工作目录 |
|
||||
| `JIANG13_COMMUNITY_HUB` | api | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||
|
||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||
**健康检查:** Go API `GET /health`、Node 渲染服务器 `GET /healthz`,均返回 200,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
|
||||
@@ -238,14 +258,16 @@ docker push hangzhang714128/jiang13-forum:latest
|
||||
|
||||
**1Panel 部署提示:**
|
||||
|
||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||
2. 端口映射 `3000:3000`
|
||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||
1. 用 Compose 编排(`docker-compose.yml`)或手动创建两个容器:`api`(Go,内部 `3001`,挂载 `/data`)与 `web`(Node,映射 `3000:3000`)
|
||||
2. `web` 容器环境变量 `GO_API_URL` 指向 `api` 容器(如 `http://api:3001`)
|
||||
3. 数据卷只需挂在 `api` 容器的 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
不想自己编译时,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件即可:
|
||||
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 同版本号的文件:
|
||||
|
||||
| 文件 | 平台 |
|
||||
|------|------|
|
||||
@@ -254,35 +276,24 @@ docker push hangzhang714128/jiang13-forum:latest
|
||||
|
||||
当前 **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)
|
||||
|
||||
把文件放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||
两个进程一起跑(Go API :3001 + Node :3000 对外):
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\jiang13-1.1.7-windows-amd64.exe
|
||||
# 或改名为 jiang13.exe 后:
|
||||
.\jiang13.exe
|
||||
# 1) 启动 Go API
|
||||
./jiang13 --port 3001 --data ./data
|
||||
|
||||
# Linux
|
||||
chmod +x jiang13-1.1.7-linux-amd64
|
||||
./jiang13-1.1.7-linux-amd64
|
||||
# 2) 另开终端,构建并启动 Node 渲染服务器
|
||||
cd frontend && npm install && npm run build
|
||||
PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
|
||||
```
|
||||
|
||||
本地刚编译的产物在 `dist/`:
|
||||
Windows:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\dist\jiang13.exe
|
||||
|
||||
# Linux / macOS
|
||||
./dist/jiang13
|
||||
```
|
||||
|
||||
也可先复制示例配置再改端口 / 数据目录:
|
||||
|
||||
```bash
|
||||
cp app.ini.example /opt/jiang13/app.ini
|
||||
# 编辑 app.ini 后:
|
||||
./jiang13
|
||||
```powershell
|
||||
.\jiang13.exe --port 3001 --data .\data
|
||||
# 另开 PowerShell:
|
||||
cd frontend; npm install; npm run build
|
||||
$env:PORT = '3000'; $env:GO_API_URL = 'http://127.0.0.1:3001'; npm start
|
||||
```
|
||||
|
||||
### 4. 首次使用
|
||||
@@ -354,16 +365,17 @@ C:\jiang13\jiang13.exe --service start
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite(纯 API 服务) |
|
||||
| **前端** | React 19 · Vike SSR · react-router · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite 8(Rolldown)双构建:`dist/client` 静态资源 + `dist/server` SSR 入口 |
|
||||
| **渲染** | Node 24 · Express + Vike `renderPage`(:3000 对外,API 反代至 Go :3001) |
|
||||
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||
|
||||
---
|
||||
|
||||
## 前端开发
|
||||
|
||||
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
|
||||
日常改前端不需要重新完整构建,Vike dev 支持秒级热更新(HMR,热模块替换):
|
||||
|
||||
```bat
|
||||
build.bat -Target dev
|
||||
@@ -373,16 +385,17 @@ build.bat -Target 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 运行数据不一致。
|
||||
|
||||
**何时需要完整构建:**
|
||||
|
||||
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||
- 修改 Go 代码 → `build.bat` / `make build`(仅编译 Go API)
|
||||
- 验证生产 SSR / 发布部署 → `make serve`(构建前端 + 双进程起本地产)
|
||||
- 更新 README 界面截图 → 见下方「更新截图」
|
||||
|
||||
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||
> 开发时请访问 `:5173`;`:3000` 只有在生产模式(Node 渲染服务器)下才有页面。
|
||||
|
||||
### 更新截图
|
||||
|
||||
@@ -412,17 +425,20 @@ jiang13-forum/
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # app.ini 与命令行配置
|
||||
├── app.ini.example # 配置文件示例
|
||||
├── Dockerfile # 多阶段 Docker 构建
|
||||
├── docker-compose.yml # 单容器 Compose 部署
|
||||
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
|
||||
├── Dockerfile # 多阶段构建(前端 + Go API 共用镜像,双容器运行)
|
||||
├── docker-compose.yml # 双容器 Compose 部署(web :3000 + api :3001)
|
||||
├── docker-entrypoint.sh # api 容器启动脚本(修正 /data 卷权限)
|
||||
├── .dockerignore
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
├── handler/ # HTTP 处理器(API + /api/home-boot SSR 聚合端点)
|
||||
├── middleware/ # JWT 鉴权等
|
||||
├── router/ # 路由注册
|
||||
├── embed_static/ # go:embed 内嵌的 SPA
|
||||
├── frontend/ # React 源码(Vite 构建)
|
||||
├── router/ # 路由注册(仅 API)
|
||||
├── frontend/
|
||||
│ ├── pages/ # Vike 页面(feed=SSR 白名单 / spa=catch-all 壳 / _error)
|
||||
│ ├── renderer/ # Vike 双端渲染入口(onRenderHtml / onRenderClient)
|
||||
│ ├── server/ # Node 渲染服务器(express + renderPage + API 反代)
|
||||
│ └── src/ # React 源码(含 src/ssr 服务端取数与作用域管理)
|
||||
├── docs/screenshots/ # README 界面截图
|
||||
├── ROADMAP.md # 路线图与已知问题
|
||||
└── scripts/ # 开发辅助脚本(含截图)
|
||||
|
||||
46
build.ps1
46
build.ps1
@@ -3,7 +3,7 @@
|
||||
# .\build.ps1 -Target build-windows
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ function Ensure-Dir($path) {
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
if (-not (Test-Path node_modules)) {
|
||||
@@ -69,13 +69,14 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
||||
|
||||
switch ($Target) {
|
||||
'help' {
|
||||
Write-Host '.\build.ps1 build current platform'
|
||||
Write-Host '.\build.ps1 -Target frontend frontend only'
|
||||
Write-Host '.\build.ps1 build Go API binary (current platform)'
|
||||
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-linux'
|
||||
Write-Host '.\build.ps1 -Target build-all'
|
||||
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||
Write-Host '.\build.ps1 -Target run Go API only (port 3000, no pages)'
|
||||
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 clean'
|
||||
Write-Host '.\build.ps1 -Target docker build Docker image'
|
||||
@@ -84,6 +85,7 @@ switch ($Target) {
|
||||
Write-Host ''
|
||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||
}
|
||||
'web-build' { Build-Frontend }
|
||||
'frontend' { Build-Frontend }
|
||||
'tidy' { go mod tidy }
|
||||
'clean' {
|
||||
@@ -94,11 +96,35 @@ switch ($Target) {
|
||||
Ensure-Dir $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' {
|
||||
$root = (Get-Location).Path
|
||||
Ensure-Dir $DevDataDir
|
||||
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] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
|
||||
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||
@@ -117,24 +143,18 @@ switch ($Target) {
|
||||
}
|
||||
}
|
||||
'build' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName
|
||||
}
|
||||
'build-windows' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
}
|
||||
'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-darwin' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
}
|
||||
'build-all' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
|
||||
@@ -98,7 +98,11 @@ func (p *program) setup() error {
|
||||
}
|
||||
|
||||
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.ConfigFile)
|
||||
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
|
||||
# 或:make compose-up / build.bat -Target compose-up
|
||||
|
||||
services:
|
||||
jiang13:
|
||||
api:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
command: ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
# 可选:挂载自定义 app.ini(只读)
|
||||
@@ -20,10 +20,34 @@ services:
|
||||
TZ: Asia/Shanghai
|
||||
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
|
||||
# JIANG13_JWT_SECRET: your-secret-here
|
||||
expose:
|
||||
- "3001"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15s
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# api 容器入口:默认运行 Go API(web 容器在 compose 中以 node 用户直接覆盖 entrypoint)。
|
||||
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
|
||||
# 而应用以 uid 1000(node 镜像内置用户)运行,需在启动时修正 /data 权限后降权。
|
||||
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R jiang13:jiang13 "$DATA_DIR"
|
||||
exec su-exec jiang13 /app/jiang13 "$@"
|
||||
chown -R 1000:1000 "$DATA_DIR"
|
||||
exec gosu 1000:1000 /app/jiang13 "$@"
|
||||
fi
|
||||
|
||||
exec /app/jiang13 "$@"
|
||||
|
||||
@@ -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,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "vike dev",
|
||||
"build": "vike build",
|
||||
"preview": "vike preview",
|
||||
"start": "node server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -21,7 +22,7 @@
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@svg-maps/china": "^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/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
@@ -32,36 +33,44 @@
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
"@tiptap/starter-kit": "^3.26.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"express": "^5.2.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"http-proxy-middleware": "^4.2.0",
|
||||
"linkedom": "^0.18.13",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"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",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vike": "^0.4.266",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.7"
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"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 scope = createRequestScope(createHomeStore());
|
||||
scope.hydrating = true;
|
||||
const { pathname, searchOriginal } = pageContext.urlParsed;
|
||||
const url = pathname + (searchOriginal ?? '');
|
||||
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 {
|
||||
createBrowserRouter,
|
||||
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';
|
||||
import { BrowserRouter, StaticRouter, useRoutes } from 'react-router';
|
||||
import AppShell from './AppShell';
|
||||
import { appRoutes } from './AppRoutes';
|
||||
|
||||
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
||||
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||
const 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'));
|
||||
/**
|
||||
* 路由树:使用低层 router(useRoutes)而非 data router(RouterProvider)。
|
||||
*
|
||||
* 原因:data router 的 RouterProvider/StaticRouterProvider 内部订阅路由状态,
|
||||
* 服务端 Fizz 渲染会产生 Suspense pending boundary(内容落入 <div hidden> +
|
||||
* 脚本替换的流式格式,禁 JS 爬虫/SEO 工具看不到帖子)。本项目数据全部由
|
||||
* Vike +data 预取并 seed 进请求作用域,不使用 loader/action,低层路由首帧
|
||||
* 即可同步渲染完整 HTML,SSR/CSR 行为一致。
|
||||
*/
|
||||
function AppRoutes() {
|
||||
return useRoutes(appRoutes);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter(
|
||||
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 />} />
|
||||
<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() {
|
||||
/**
|
||||
* 应用根组件:Vike +Page 与客户端入口共用(浏览器侧)。
|
||||
*/
|
||||
export default function App(_props: { url?: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<TopProgressBar />
|
||||
<PullToRefresh />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell>
|
||||
<AppRoutes />
|
||||
</AppShell>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
user_id: number;
|
||||
title: string;
|
||||
/** 完整正文;列表接口不下发(为空),仅帖子详情等场景有值 */
|
||||
content?: string;
|
||||
/** 列表接口预算的纯文本摘要(60 字),列表页直接使用无需解析 content */
|
||||
excerpt?: string;
|
||||
/** 列表接口预算的正文首图 URL(缩略图模式用) */
|
||||
first_image?: string;
|
||||
tags: string;
|
||||
/** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */
|
||||
post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { TableKit } from '@tiptap/extension-table';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
@@ -121,7 +121,7 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
|
||||
@@ -268,13 +268,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
ImageGroup,
|
||||
Placeholder.configure({
|
||||
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;
|
||||
}
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
|
||||
if (node.type.name === 'paragraph' && parentName === 'replyOnly') {
|
||||
return REPLY_ONLY_PLACEHOLDER;
|
||||
}
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') {
|
||||
if (node.type.name === 'paragraph' && parentName === 'pointsOnly') {
|
||||
return '此处内容需积分解锁后可见…';
|
||||
}
|
||||
return placeholder;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
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
|
||||
*/
|
||||
function pickScrollEl(scope: ParentNode): HTMLElement | null {
|
||||
@@ -52,7 +53,7 @@ export default function BackToTop() {
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let bound: HTMLElement | null = null;
|
||||
let bound: EventTarget | null = null;
|
||||
|
||||
const onScroll = () => {
|
||||
if (!cancelled) syncVisible();
|
||||
@@ -80,6 +81,15 @@ export default function BackToTop() {
|
||||
|
||||
const tryBind = () => {
|
||||
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();
|
||||
if (!scope) {
|
||||
setVisible(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { api } from '../api/client';
|
||||
import { renderCommentContent } from '../utils/content';
|
||||
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
|
||||
|
||||
@@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
||||
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
|
||||
@@ -36,7 +36,7 @@ interface Props {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -148,7 +148,9 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value,
|
||||
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]);
|
||||
|
||||
const insertSticker = useCallback((sticker: Sticker) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
||||
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
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 commentCount = post.comment_count ?? 0;
|
||||
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 excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : '';
|
||||
const excerpt = showExcerpt ? (post.excerpt || excerptFromHTML(post.content || '', 60)) : '';
|
||||
const showBoardBadge = !!post.board && boardId !== post.board.id;
|
||||
|
||||
const openPost = () => onSelect(post.id);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ function pickScrollEl(): HTMLElement | 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 整栏滚动(板块 / 排序栏可滚走)
|
||||
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
||||
if (mobileFeed) return mobileFeed;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from '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 { PostHeading } from '../utils/postHeadings';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Globe2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { api } from '../api/client';
|
||||
import type { CommunityShowcaseItem } from '../api/types';
|
||||
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
|
||||
} 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 { PostHeading } from '../utils/postHeadings';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useMediaQuery } from '../hooks/useTheme';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { useSitePages } from '../hooks/useSitePages';
|
||||
import { pagePath } from '../utils/permalink';
|
||||
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 > 0 ? (
|
||||
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
||||
) : null}
|
||||
{(post.board_pinned ?? 0) > 0 ? (
|
||||
<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 { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { TagCount } from '../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserBadge } from '../api/types';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ForumLimitsPublic } from '../api/types';
|
||||
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
|
||||
import { getScope } from '../ssr/scope';
|
||||
|
||||
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
post_title_max: 128,
|
||||
@@ -38,6 +39,11 @@ let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||
let cacheEpoch = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** 当前生效的论坛限制:SSR 读请求作用域,客户端读模块缓存 */
|
||||
function currentLimits(): ForumLimitsPublic | null {
|
||||
return getScope()?.limits ?? cached;
|
||||
}
|
||||
|
||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (inflight) return inflight;
|
||||
@@ -53,8 +59,9 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
|
||||
/** 获取前台可见的论坛限制配置 */
|
||||
export function useForumLimits() {
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(currentLimits() ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!currentLimits());
|
||||
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,8 +101,13 @@ export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promi
|
||||
return limits;
|
||||
}
|
||||
|
||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */
|
||||
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
||||
const scope = getScope();
|
||||
if (scope) {
|
||||
scope.limits = limits;
|
||||
return;
|
||||
}
|
||||
cached = limits;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
@@ -110,5 +122,5 @@ export function invalidateForumLimitsCache() {
|
||||
|
||||
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
||||
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 { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from './useForumLimits';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { navigateFeed } from '../utils/feedCache';
|
||||
import { transitionTo } from '../utils/spaTransition';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { SiteBranding } from '../api/types';
|
||||
import { getScope } from '../ssr/scope';
|
||||
|
||||
export const DEFAULT_BRANDING: SiteBranding = {
|
||||
name: '姜十三论坛',
|
||||
@@ -30,8 +31,8 @@ export function siteMetaDescription(brand: SiteBranding): string {
|
||||
return brand.slogan?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
|
||||
function readBootBranding(): SiteBranding | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const boot = window.__J13_BRANDING__;
|
||||
if (!boot || typeof boot !== 'object') return null;
|
||||
@@ -50,6 +51,11 @@ let cacheEpoch = 0;
|
||||
let preferCacheOnce = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */
|
||||
function currentBranding(): SiteBranding | null {
|
||||
return getScope()?.branding ?? cached;
|
||||
}
|
||||
|
||||
function fetchBranding(): Promise<SiteBranding> {
|
||||
if (preferCacheOnce && cached) {
|
||||
preferCacheOnce = false;
|
||||
@@ -96,13 +102,13 @@ function applyDocumentBrand(brand: SiteBranding) {
|
||||
|
||||
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
||||
export function getCachedSiteBranding(): SiteBranding {
|
||||
return cached ?? DEFAULT_BRANDING;
|
||||
return currentBranding() ?? DEFAULT_BRANDING;
|
||||
}
|
||||
|
||||
/** 获取站点品牌配置(名称、Logo 等) */
|
||||
export function useSiteBranding() {
|
||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [branding, setBranding] = useState<SiteBranding>(currentBranding() ?? DEFAULT_BRANDING);
|
||||
const [loading, setLoading] = useState(!currentBranding());
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,9 +166,15 @@ export function invalidateSiteBrandingCache() {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
||||
/** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */
|
||||
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);
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { SitePageSummary } from '../api/types';
|
||||
import { getScope } from '../ssr/scope';
|
||||
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||
|
||||
let cache: SitePageSummary[] | null = null;
|
||||
let pending: Promise<SitePageSummary[]> | null = null;
|
||||
|
||||
/** 当前生效的站点页摘要:SSR 读请求作用域,客户端读模块缓存 */
|
||||
function currentPages(): SitePageSummary[] | null {
|
||||
return getScope()?.pages ?? cache;
|
||||
}
|
||||
|
||||
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
||||
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
||||
const cur = currentPages();
|
||||
if (opts?.force) {
|
||||
cache = null;
|
||||
pending = null;
|
||||
}
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (cur) return Promise.resolve(cur);
|
||||
if (!pending) {
|
||||
pending = api.pages()
|
||||
.then(d => {
|
||||
@@ -31,7 +38,7 @@ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SiteP
|
||||
|
||||
/** 已发布单页摘要(页脚/侧栏导航) */
|
||||
export function useSitePages() {
|
||||
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
|
||||
const [pages, setPages] = useState<SitePageSummary[]>(currentPages() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
@@ -68,8 +75,14 @@ export function invalidateSitePagesCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** 文档 SSR:同步写入站点页摘要缓存 */
|
||||
/** 文档 SSR:同步写入站点页摘要缓存(SSR 时只写请求作用域) */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
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) {
|
||||
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
|
||||
const [match, setMatch] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const m = window.matchMedia(query);
|
||||
const fn = () => setMatch(m.matches);
|
||||
m.addEventListener('change', fn);
|
||||
return () => m.removeEventListener('change', fn);
|
||||
const sync = () => setMatch(m.matches);
|
||||
sync();
|
||||
m.addEventListener('change', sync);
|
||||
return () => m.removeEventListener('change', sync);
|
||||
}, [query]);
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { useBlocker } from 'react-router';
|
||||
|
||||
interface Options {
|
||||
/** 是否存在未保存的修改 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router';
|
||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -51,21 +51,18 @@ export default function MainLayout() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
useMonitorPageview();
|
||||
const mqMobile = useMediaQuery('(max-width: 768px)');
|
||||
// 响应式分支首帧恒为桌面(useMediaQuery hydration 安全),layout effect 绘制前同步真实值
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||
/** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */
|
||||
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
|
||||
const isMobile = forceDesktop ? false : mqMobile;
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
||||
|
||||
/** hydrate 首帧提交后清除同构标志(shellReady/冷启动门闩在首帧渲染期读取) */
|
||||
useEffect(() => {
|
||||
if (!forceDesktop) return;
|
||||
endHomeHydrate();
|
||||
startTransition(() => setForceDesktop(false));
|
||||
}, [forceDesktop]);
|
||||
}, []);
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
@@ -92,9 +89,9 @@ export default function MainLayout() {
|
||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
|
||||
const asideEverLoaded = useRef(false);
|
||||
/** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */
|
||||
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
|
||||
const coldBootDone = useRef(isCompose || wasColdBootEnsured());
|
||||
/** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */
|
||||
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating());
|
||||
const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating());
|
||||
const bootGen = useRef(0);
|
||||
const [boardId, setBoardId] = useState(() => {
|
||||
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
||||
@@ -214,7 +211,11 @@ export default function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshBoards();
|
||||
// SSR +data 已 seed boards/stats(或 SPA 冷启动已预热)时跳过首拉,避免重复请求;
|
||||
// 后台操作 / 手动刷新经 boards-refresh 事件强制重拉
|
||||
if (getCachedBoards().length === 0 || getCachedStats() === null) {
|
||||
void refreshBoards();
|
||||
}
|
||||
const onRefresh = () => refreshBoards();
|
||||
window.addEventListener('boards-refresh', onRefresh);
|
||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||
@@ -541,7 +542,8 @@ export default function MainLayout() {
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
@@ -820,11 +822,10 @@ export default function MainLayout() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{shellReady ? (
|
||||
<Suspense fallback={null}>
|
||||
<Outlet context={layoutCtx} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* 不在此处包 Suspense:React 19 Fizz 会把 Suspense 边界内大体积/含 <img>
|
||||
的同步内容 outlining 为 <div hidden> 分块(禁 JS 爬虫不可见)。
|
||||
懒加载路由的 Suspense 已在 AppRoutes 各路由 element 上就近声明。 */}
|
||||
{shellReady ? <Outlet context={layoutCtx} /> : null}
|
||||
</main>
|
||||
|
||||
{!isCompose && (
|
||||
|
||||
@@ -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 { useForm } from 'react-hook-form';
|
||||
import { useForm, type Resolver } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Plus, FolderKanban } from 'lucide-react';
|
||||
@@ -52,8 +52,9 @@ export default function BoardsManagePage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
|
||||
// z.coerce 的 input 类型为 unknown,这里显式断言对齐 output 类型
|
||||
const form = useForm<BoardFormValues>({
|
||||
resolver: zodResolver(boardSchema),
|
||||
resolver: zodResolver(boardSchema) as Resolver<BoardFormValues>,
|
||||
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowLeft, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { zodResolver } from '@hookform/resolvers/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 {
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
@@ -6,18 +6,23 @@ import {
|
||||
useLocation,
|
||||
useParams,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
} from 'react-router';
|
||||
import { Inbox, SearchX } from 'lucide-react';
|
||||
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 type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import StaticFeedList from '../components/StaticFeedList';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import FeedPagination from '../components/FeedPagination';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||
import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||
import {
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { startTransition, doneTransition } from '../utils/spaTransition';
|
||||
import { isHomeHydrating } from '../utils/homeHydrate';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||
@@ -49,7 +53,6 @@ type FeedHydrate = {
|
||||
posts: PostItem[];
|
||||
postTotal: number;
|
||||
page: number;
|
||||
scrollTop: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
@@ -69,11 +72,10 @@ function readHydrate(
|
||||
posts: cached.posts,
|
||||
postTotal: cached.postTotal,
|
||||
page: cached.page,
|
||||
scrollTop: cached.scrollTop,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
|
||||
return { posts: [], postTotal: 0, page: 1, loading: true };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -83,6 +85,7 @@ export default function HomePage() {
|
||||
const { id: boardRouteId } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { user } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
const { limits, loading: limitsLoading } = useForumLimits();
|
||||
const postSearch = usePostSearch(limits);
|
||||
@@ -187,25 +190,14 @@ export default function HomePage() {
|
||||
titleOnly,
|
||||
});
|
||||
const [listPending, setListPending] = useState(false);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
||||
);
|
||||
/** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
|
||||
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 pageRef = useRef(initial.page);
|
||||
const cacheKeyRef = useRef(cacheKey);
|
||||
const viewKeyRef = useRef(view.cacheKey);
|
||||
const postsRef = useRef(posts);
|
||||
const scrollRafRef = useRef(0);
|
||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||
const hydratedKeyRef = useRef<string | null>(null);
|
||||
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
||||
@@ -223,8 +215,6 @@ export default function HomePage() {
|
||||
setPostTotal(next.postTotal);
|
||||
setPage(next.page);
|
||||
pageRef.current = next.page;
|
||||
scrollTopRef.current = next.scrollTop;
|
||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
||||
setLoading(next.loading);
|
||||
setListPending(false);
|
||||
setView(meta);
|
||||
@@ -254,10 +244,10 @@ export default function HomePage() {
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
const hasMore = page < totalPages;
|
||||
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
|
||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
@@ -266,12 +256,12 @@ export default function HomePage() {
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
|
||||
/** 把当前列表写入 Zustand */
|
||||
const persistFeed = useCallback((
|
||||
nextPosts: PostItem[],
|
||||
nextTotal: number,
|
||||
nextPage: number,
|
||||
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
|
||||
opts?: { touchFetchTime?: boolean; key?: string },
|
||||
) => {
|
||||
const key = opts?.key ?? cacheKeyRef.current;
|
||||
const prev = getHomeStoreState().getFeed(key);
|
||||
@@ -279,7 +269,6 @@ export default function HomePage() {
|
||||
posts: nextPosts,
|
||||
postTotal: nextTotal,
|
||||
page: nextPage,
|
||||
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
|
||||
lastFetchTime: opts?.touchFetchTime === false
|
||||
? (prev?.lastFetchTime ?? Date.now())
|
||||
: Date.now(),
|
||||
@@ -309,10 +298,7 @@ export default function HomePage() {
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
const total = data.total ?? 0;
|
||||
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
||||
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
|
||||
if (jumpTop) {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
}
|
||||
setPosts(batch);
|
||||
@@ -320,7 +306,7 @@ export default function HomePage() {
|
||||
setPage(p);
|
||||
pageRef.current = p;
|
||||
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) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
if (!silent) {
|
||||
@@ -352,8 +338,6 @@ export default function HomePage() {
|
||||
}, [fetchPage, postTotal, pageSize]);
|
||||
|
||||
const handleSelectPost = useCallback((id: number) => {
|
||||
// 离开前再写一次滚动,确保详情页返回可还原
|
||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||
}, [nav, limits.open_posts_in_new_tab]);
|
||||
|
||||
@@ -399,7 +383,6 @@ export default function HomePage() {
|
||||
posts: cached.posts,
|
||||
postTotal: cached.postTotal,
|
||||
page: cached.page,
|
||||
scrollTop: cached.scrollTop,
|
||||
loading: false,
|
||||
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||
} else {
|
||||
@@ -415,10 +398,6 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
hydratedKeyRef.current = cacheKey;
|
||||
if (postsRef.current.length === 0) {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
}
|
||||
loadFirst();
|
||||
}, [
|
||||
limitsLoading,
|
||||
@@ -466,7 +445,6 @@ export default function HomePage() {
|
||||
posts: warm.posts,
|
||||
postTotal: warm.postTotal,
|
||||
page: warm.page,
|
||||
scrollTop: 0,
|
||||
loading: false,
|
||||
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
||||
setListResetKey((k) => k + 1);
|
||||
@@ -495,20 +473,21 @@ export default function HomePage() {
|
||||
sort, boardId, keyword, tag, author, titleOnly,
|
||||
]);
|
||||
|
||||
// 卸载时取消未执行的 scroll rAF
|
||||
useEffect(() => () => {
|
||||
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
|
||||
}, []);
|
||||
// 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
|
||||
// - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
|
||||
// - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
|
||||
// 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
|
||||
useLayoutEffect(() => {
|
||||
if (listResetKey <= 0) return;
|
||||
window.scrollTo(0, 0);
|
||||
}, [listResetKey]);
|
||||
|
||||
const handleScrollTopChange = useCallback((top: number) => {
|
||||
scrollTopRef.current = top;
|
||||
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
|
||||
if (scrollRafRef.current) return;
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
||||
});
|
||||
}, []);
|
||||
// PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
|
||||
// POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
|
||||
useEffect(() => {
|
||||
if (navType === 'POP') return;
|
||||
window.scrollTo(0, 0);
|
||||
}, [cacheKey, navType]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
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 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) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
@@ -546,25 +551,13 @@ export default function HomePage() {
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<div className="feed-top__bar">
|
||||
{!useVirtualList ? (
|
||||
(view.keyword || view.tag || view.author) ? (
|
||||
<h1 className="feed-header-title">
|
||||
{view.tag
|
||||
? `#${view.tag}`
|
||||
: view.keyword
|
||||
? `搜索:${view.keyword}`
|
||||
: `作者:${view.author}`}
|
||||
</h1>
|
||||
) : null
|
||||
) : (
|
||||
<FeedHeader
|
||||
keyword={view.keyword}
|
||||
tag={view.tag}
|
||||
author={view.author}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
)}
|
||||
<FeedHeader
|
||||
keyword={view.keyword}
|
||||
tag={view.tag}
|
||||
author={view.author}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar
|
||||
value={view.sort}
|
||||
@@ -583,36 +576,79 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!useVirtualList ? (
|
||||
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
|
||||
) : (
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={view.sort}
|
||||
loading={listPending ? false : (loading || limitsLoading)}
|
||||
hasMore={hasMore}
|
||||
showPagination={showPagination}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
onPageChange={goToPage}
|
||||
onSelect={handleSelectPost}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={handleScrollTopChange}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={view.keyword || view.tag || view.author}
|
||||
isSearchMode={!!(view.keyword || view.author)}
|
||||
searchKeyword={view.keyword}
|
||||
searchAuthor={view.author}
|
||||
searchTitleOnly={view.titleOnly}
|
||||
searchScopeBoardId={searchFilters.scopeBoardId}
|
||||
onClearSearch={postSearch.clearSearch}
|
||||
boardId={view.boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
|
||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
|
||||
不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
|
||||
也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
|
||||
*/}
|
||||
<div className="post-list-scroll post-list-scroll--ssr">
|
||||
{posts.length === 0 ? (
|
||||
<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">{emptyHint}</p>
|
||||
<div className="empty-feed-actions">
|
||||
{noBoards ? (
|
||||
isAdmin ? (
|
||||
<Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
|
||||
)
|
||||
) : isSearchEmpty ? (
|
||||
<>
|
||||
<Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
|
||||
<Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
|
||||
<Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
|
||||
</>
|
||||
) : view.boardId > 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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import {
|
||||
ArrowLeft,
|
||||
AtSign,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FileQuestion, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router';
|
||||
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -54,7 +54,6 @@ import { loadMyCommentIds } from '../utils/guest';
|
||||
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||
@@ -79,7 +78,6 @@ type PostDetailSnapshot = {
|
||||
bountyCanRefund: boolean;
|
||||
bountyRefundBlockReason: string;
|
||||
bountyEligibleReplyCount: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
function postDetailCacheKey(id: number) {
|
||||
@@ -145,24 +143,15 @@ export default function PostDetailPage() {
|
||||
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
|
||||
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
|
||||
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
|
||||
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
|
||||
if (!initialSnap) return null;
|
||||
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const postRef = useRef<PostItem | null>(null);
|
||||
const scrollTopRef = useRef(
|
||||
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
|
||||
);
|
||||
postRef.current = post;
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
// 滚动位置完全交给浏览器原生历史恢复(详情页为文档滚动,见 .app-shell--doc-scroll),
|
||||
// 会话快照只缓存数据让 POP 后退时内容即时渲染、文档即刻够高,原生恢复才有正确落点。
|
||||
|
||||
// SPA 内跳转时纠正非规范伪静态路径
|
||||
useEffect(() => {
|
||||
@@ -212,7 +201,7 @@ export default function PostDetailPage() {
|
||||
}
|
||||
setPostOutline({
|
||||
headings,
|
||||
scrollRoot: pageRef.current,
|
||||
scrollRoot: null,
|
||||
title: '文章目录',
|
||||
author: post.user ?? null,
|
||||
publishedAt: post.created_at,
|
||||
@@ -238,15 +227,9 @@ export default function PostDetailPage() {
|
||||
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
||||
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
||||
setComments(snap.comments);
|
||||
if (opts?.restoreScroll) {
|
||||
scrollTopRef.current = snap.scrollTop;
|
||||
setRestoreScrollTop(snap.scrollTop);
|
||||
} else {
|
||||
scrollTopRef.current = 0;
|
||||
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
|
||||
const el = pageRef.current;
|
||||
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
|
||||
}
|
||||
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
||||
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
||||
if (!opts?.restoreScroll) window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
|
||||
@@ -304,15 +287,9 @@ export default function PostDetailPage() {
|
||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
||||
scrollTop: 0,
|
||||
};
|
||||
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
||||
applySnapshot(snap, { restoreScroll: false });
|
||||
if (mode === 'force') {
|
||||
const el = pageRef.current;
|
||||
if (el) el.scrollTop = 0;
|
||||
scrollTopRef.current = 0;
|
||||
}
|
||||
void refresh();
|
||||
} catch {
|
||||
if (seq !== loadSeq.current) return;
|
||||
@@ -342,23 +319,6 @@ export default function PostDetailPage() {
|
||||
};
|
||||
}, [postId, applySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = pageRef.current;
|
||||
if (!el || loading || !post) return;
|
||||
const onScroll = () => {
|
||||
scrollTopRef.current = el.scrollTop;
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [loading, post]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
|
||||
pageRef.current.scrollTop = restoreScrollTop;
|
||||
scrollTopRef.current = restoreScrollTop;
|
||||
setRestoreScrollTop(null);
|
||||
}, [restoreScrollTop, loading, post]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!post || post.id !== postId || loading) return;
|
||||
setSessionSnapshot(postDetailCacheKey(post.id), {
|
||||
@@ -375,7 +335,6 @@ export default function PostDetailPage() {
|
||||
bountyCanRefund,
|
||||
bountyRefundBlockReason,
|
||||
bountyEligibleReplyCount,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
}, [
|
||||
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
||||
@@ -383,14 +342,6 @@ export default function PostDetailPage() {
|
||||
bountyEligibleReplyCount, postId, loading,
|
||||
]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const p = postRef.current;
|
||||
if (!p) return;
|
||||
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
|
||||
if (!prev) return;
|
||||
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
|
||||
}, []);
|
||||
|
||||
const reloadComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
@@ -460,102 +411,55 @@ export default function PostDetailPage() {
|
||||
return () => root.removeEventListener('focusout', onFocusOut);
|
||||
}, [isMobile, composerOpen]);
|
||||
|
||||
/**
|
||||
* 详情页为窗口滚动:scroll-margin-top 预留 sticky 顶栏空间。
|
||||
* 首屏图片/字体加载会持续改变布局,落点需短暂校正;用户手动滚动立即让位。
|
||||
*/
|
||||
const scrollAnchorIntoView = useCallback((el: HTMLElement) => {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
|
||||
let interrupted = false;
|
||||
const yieldToUser = () => { interrupted = true; };
|
||||
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||
|
||||
const start = Date.now();
|
||||
(function correct() {
|
||||
window.removeEventListener('wheel', yieldToUser);
|
||||
window.removeEventListener('touchmove', yieldToUser);
|
||||
if (interrupted || !el.isConnected || Date.now() - start > 1500) return;
|
||||
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||||
if (Math.abs(el.getBoundingClientRect().top - margin) > 4) {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||
window.setTimeout(correct, 120);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return false;
|
||||
|
||||
const root = pageRef.current;
|
||||
if (!root) return false;
|
||||
|
||||
// 直接计算目标滚动位置
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const relativeTop = elRect.top - rootRect.top;
|
||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
const targetTop = root.scrollTop + relativeTop - 24;
|
||||
|
||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||
|
||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||
|
||||
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
|
||||
root.scrollTop = clampedTop;
|
||||
|
||||
// 验证滚动结果,如果不准确则再次调整
|
||||
// 注意:使用动态计算的 currentMaxScrollTop,因为评论可能在之后才渲染完成
|
||||
const verifyAndFix = () => {
|
||||
const currentElRect = el.getBoundingClientRect();
|
||||
const currentRootRect = root.getBoundingClientRect();
|
||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
|
||||
// 如果元素在视口内但位置不准确,进行修正
|
||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
|
||||
const correction = currentRelativeTop - 24;
|
||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||
root.scrollTop = newTarget;
|
||||
}
|
||||
};
|
||||
|
||||
// 在多个时间点验证和调整
|
||||
requestAnimationFrame(verifyAndFix);
|
||||
setTimeout(verifyAndFix, 50);
|
||||
setTimeout(verifyAndFix, 150);
|
||||
scrollAnchorIntoView(el);
|
||||
|
||||
setHighlightFloor(floor);
|
||||
clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
return true;
|
||||
}, []);
|
||||
}, [scrollAnchorIntoView]);
|
||||
|
||||
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
|
||||
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
||||
/** 正文标题锚点:等正文进 DOM 后由调用方重试 */
|
||||
const jumpToHeadingHash = useCallback((hash: string) => {
|
||||
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
|
||||
if (!id || /^floor-\d+$/.test(id)) return false;
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return false;
|
||||
|
||||
const root = pageRef.current;
|
||||
if (!root) return false;
|
||||
|
||||
// 直接计算目标滚动位置
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const relativeTop = elRect.top - rootRect.top;
|
||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
const targetTop = root.scrollTop + relativeTop - 12;
|
||||
|
||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||
|
||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||
|
||||
// 直接设置滚动位置
|
||||
root.scrollTop = clampedTop;
|
||||
|
||||
// 验证滚动结果,如果不准确则再次调整
|
||||
const verifyAndFix = () => {
|
||||
const currentElRect = el.getBoundingClientRect();
|
||||
const currentRootRect = root.getBoundingClientRect();
|
||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
|
||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
|
||||
const correction = currentRelativeTop - 12;
|
||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||
root.scrollTop = newTarget;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(verifyAndFix);
|
||||
if (smooth) {
|
||||
setTimeout(verifyAndFix, 50);
|
||||
setTimeout(verifyAndFix, 150);
|
||||
}
|
||||
|
||||
scrollAnchorIntoView(el);
|
||||
return true;
|
||||
}, []);
|
||||
}, [scrollAnchorIntoView]);
|
||||
|
||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
|
||||
useEffect(() => {
|
||||
@@ -597,7 +501,7 @@ export default function PostDetailPage() {
|
||||
|
||||
const tryJump = () => {
|
||||
if (cancelled) return;
|
||||
if (jumpToHeadingHash(hash, false)) return;
|
||||
if (jumpToHeadingHash(hash)) return;
|
||||
attempts += 1;
|
||||
if (attempts < 30) {
|
||||
timer = window.setTimeout(tryJump, 50);
|
||||
@@ -946,7 +850,7 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<article className="page-wrap post-detail-page">
|
||||
<div className="post-detail-header">
|
||||
<div className="post-detail-nav">
|
||||
<div className="post-detail-nav-left">
|
||||
@@ -1083,7 +987,6 @@ export default function PostDetailPage() {
|
||||
<summary>文章目录({headings.length})</summary>
|
||||
<ArticleOutline
|
||||
headings={headings}
|
||||
scrollRoot={pageRef.current}
|
||||
title="目录"
|
||||
/>
|
||||
</details>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -91,7 +91,7 @@ export default function ProfilePage() {
|
||||
const [idCopied, setIdCopied] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const sigSectionRef = useRef<HTMLDivElement>(null);
|
||||
const pendingFocusSig = useRef(false);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowLeft, FolderGit2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowLeft, ExternalLink, Globe2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useOutletContext, useParams } from 'react-router-dom';
|
||||
import { useOutletContext, useParams } from 'react-router';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import { api } from '../api/client';
|
||||
import type { SitePage } from '../api/types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
|
||||
@@ -170,9 +170,9 @@ export default function AdminBadgesPage() {
|
||||
setReordering(true);
|
||||
try {
|
||||
const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered);
|
||||
const after = await persistSortOrderChanges(before, merged, badge =>
|
||||
api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order }),
|
||||
);
|
||||
const after = await persistSortOrderChanges(before, merged, async badge => {
|
||||
await api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order });
|
||||
});
|
||||
setRows(after);
|
||||
notify.success('徽章排序已更新');
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -53,7 +53,7 @@ export default function AdminCommentsPage() {
|
||||
const [revComment, setRevComment] = useState<Comment | null>(null);
|
||||
const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null);
|
||||
const focusTriedRef = useRef(false);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const loadList = (p = page, st: Tab = tab) => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { Globe2, ExternalLink, Star } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import {
|
||||
Link2, Plus, Trash2, Check, X, ExternalLink, Eye, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
@@ -173,24 +173,27 @@ export default function AdminLinksPage() {
|
||||
aside_show_friend_links: s.limits?.aside_show_friend_links ?? true,
|
||||
}),
|
||||
);
|
||||
const base: ForumLimits = {
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
...s.limits,
|
||||
};
|
||||
// 默认值在前、server limits 覆盖在后(JSON 缺 key 不覆盖)
|
||||
const base: ForumLimits = Object.assign(
|
||||
{
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
} as ForumLimits,
|
||||
s.limits,
|
||||
);
|
||||
setForumLimits(mergeForumLimitsWithAsideWidgets(base, loadedAsideWidgets));
|
||||
})
|
||||
.catch(() => { /* 入口开关稍后仍可按默认展示 */ });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Activity, AlertTriangle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -76,8 +76,8 @@ export default function AdminPagesPage() {
|
||||
const before = [...rows];
|
||||
setReordering(true);
|
||||
try {
|
||||
const after = await persistSortOrderChanges(before, reordered, page =>
|
||||
api.adminUpdatePage(page.id, {
|
||||
const after = await persistSortOrderChanges(before, reordered, async page => {
|
||||
await api.adminUpdatePage(page.id, {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
content: page.content,
|
||||
@@ -85,8 +85,8 @@ export default function AdminPagesPage() {
|
||||
sort_order: page.sort_order,
|
||||
show_in_footer: page.show_in_footer,
|
||||
show_in_nav: page.show_in_nav,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
setRows(after);
|
||||
invalidateSitePagesCache();
|
||||
notify.success('单页排序已更新');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Search, Lock, LockOpen, MessageSquareOff, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -46,7 +46,7 @@ export default function AdminPostsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null);
|
||||
const focusTriedRef = useRef(false);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
@@ -294,24 +294,27 @@ export default function AdminSettingsPage() {
|
||||
}),
|
||||
);
|
||||
setAsideWidgets(loadedAsideWidgets);
|
||||
const baseLimits: ForumLimits = {
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
feed_sort_tabs: normalizeFeedSortTabs(s.limits?.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS),
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
...s.limits,
|
||||
};
|
||||
// 默认值在前、server limits 覆盖在后(JSON 缺 key 不覆盖)
|
||||
const baseLimits: ForumLimits = Object.assign(
|
||||
{
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
feed_sort_tabs: normalizeFeedSortTabs(s.limits?.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS),
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
} as ForumLimits,
|
||||
s.limits,
|
||||
);
|
||||
setLimits(mergeForumLimitsWithAsideWidgets(baseLimits, loadedAsideWidgets));
|
||||
setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) });
|
||||
setMail({ ...EMPTY_MAIL, ...s.mail, password: '' });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { ArrowLeft, ExternalLink, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Award, Ban, BadgeCheck, MoreHorizontal, Search, Shield, UserCog,
|
||||
} from 'lucide-react';
|
||||
|
||||
124
frontend/src/ssr/feedData.ts
Normal file
124
frontend/src/ssr/feedData.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Vike +data 服务端取数(SSR 白名单页:/ 、/board/:id 、/post/:id)。
|
||||
*
|
||||
* - feed(首页/板块):单次调用 Go /api/home-boot 聚合(对齐旧 homeBootPayload)
|
||||
* - post:并行取 帖子详情 + 评论 + home-boot 壳层(侧栏/登录态 SSR 一致渲染)
|
||||
* - 透传 cookie 登录态;转发 host / X-Forwarded-* 让 Go 推导站点 base URL
|
||||
* - Go 不可达/取数失败:返回不含数据的 FeedData 降级为 SPA 壳,不阻断渲染
|
||||
* - head:移植 Go spa_meta.go/seo.go,由 onRenderHtml 注入 <head>
|
||||
*/
|
||||
import { redirect, render } from 'vike/abort';
|
||||
import type { PageContextServer } from 'vike/types';
|
||||
import { serverApi } from '../api/serverClient';
|
||||
import type { Comment, PostDetailResponse } from '../api/types';
|
||||
import type { HomeBootPayload } from '../utils/homeBoot';
|
||||
import { buildPostDetailSnapshot } from '../utils/homeBoot';
|
||||
import { matchFeedSsrPath } from './feedPaths';
|
||||
import { buildFeedHead, buildNotFoundHead, buildPostHead } from './head';
|
||||
import type { FeedData } from './feedSeed';
|
||||
|
||||
export type { FeedData };
|
||||
|
||||
/** +data 入口(pages/feed/+data.ts 调用) */
|
||||
export async function loadFeedData(pageContext: PageContextServer): Promise<FeedData> {
|
||||
// 客户端(Vike client router 触发的导航)不取数:SPA 内页面组件自行请求
|
||||
if (!import.meta.env.SSR) return { kind: 'home' };
|
||||
|
||||
const headers = pageContext.headers ?? {};
|
||||
const cookie = headers.cookie ?? '';
|
||||
const apiHeaders: Record<string, string> = {
|
||||
// Go requestOrigin() 读 X-Forwarded-Host/Proto 推导站点 base URL
|
||||
'x-forwarded-host': headers['x-forwarded-host'] || headers.host || '',
|
||||
'x-forwarded-proto': headers['x-forwarded-proto'] || 'http',
|
||||
};
|
||||
|
||||
const match = matchFeedSsrPath(pageContext.urlPathname);
|
||||
// +route 已保证只放行 feed 路由;兜底降级 SPA 壳
|
||||
if (!match) return { kind: 'home' };
|
||||
const { pathname } = pageContext.urlParsed;
|
||||
const searchParams = new URLSearchParams(pageContext.urlParsed.searchOriginal ?? '');
|
||||
|
||||
if (match.kind === 'post') {
|
||||
return loadPostData(match.id, pathname, { cookie, apiHeaders });
|
||||
}
|
||||
return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders });
|
||||
}
|
||||
|
||||
type ApiCtx = { cookie: string; apiHeaders: Record<string, string> };
|
||||
|
||||
/** 帖子详情:详情 + 评论 + 壳层并行取数 */
|
||||
async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise<FeedData> {
|
||||
const pid = /^\d+$/.test(id) ? Number(id) : NaN;
|
||||
if (Number.isNaN(pid) || pid <= 0) {
|
||||
throw render(404, { head: buildNotFoundHead(pathname) });
|
||||
}
|
||||
|
||||
const [detail, commentsResp, boot] = await Promise.all([
|
||||
serverApi<PostDetailResponse>(`/api/posts/${pid}`, api),
|
||||
serverApi<{ comments: Comment[]; total: number }>(`/api/posts/${pid}/comments`, api),
|
||||
serverApi<HomeBootPayload>('/api/home-boot', api),
|
||||
]);
|
||||
|
||||
const brand = boot?.branding ?? null;
|
||||
const base = brand?.site_url?.trim() ?? '';
|
||||
|
||||
// 帖子不存在/无权限:壳层照常 seed(客户端渲染帖子自己的未找到态),HTTP 404 + noindex
|
||||
if (!detail) {
|
||||
throw render(404, {
|
||||
head: buildNotFoundHead(pathname, brand, base),
|
||||
boot: boot ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : [];
|
||||
const snapshot = buildPostDetailSnapshot(detail, comments);
|
||||
const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base);
|
||||
return { kind: 'post', boot: boot ?? undefined, snapshot, head };
|
||||
}
|
||||
|
||||
/** 首页/板块:单次 home-boot 聚合 */
|
||||
async function loadFeedListData(
|
||||
kind: 'home' | 'board',
|
||||
id: string,
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
api: ApiCtx,
|
||||
): Promise<FeedData> {
|
||||
// 旧版 /?board=id → 301 规范板块路径(保留其余 query,与旧 Go ServePublicSPA 一致)
|
||||
if (kind === 'home') {
|
||||
const legacy = searchParams.get('board');
|
||||
if (legacy && /^\d+$/.test(legacy) && Number(legacy) > 0) {
|
||||
searchParams.delete('board');
|
||||
const rest = searchParams.toString();
|
||||
throw redirect(`/board/${legacy}${rest ? `?${rest}` : ''}`, 301);
|
||||
}
|
||||
}
|
||||
|
||||
const boardId = kind === 'board' && /^\d+$/.test(id) ? Number(id) : 0;
|
||||
if (kind === 'board' && boardId <= 0) {
|
||||
throw render(404, { head: buildNotFoundHead(pathname) });
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (boardId > 0) qs.set('board_id', String(boardId));
|
||||
for (const key of ['sort', 'keyword', 'tag', 'author', 'title_only'] as const) {
|
||||
const v = searchParams.get(key);
|
||||
if (v) qs.set(key, v);
|
||||
}
|
||||
const query = qs.toString();
|
||||
const boot = await serverApi<HomeBootPayload>(`/api/home-boot${query ? `?${query}` : ''}`, api);
|
||||
if (!boot) return { kind };
|
||||
|
||||
const brand = boot.branding ?? null;
|
||||
const base = brand?.site_url?.trim() ?? '';
|
||||
|
||||
// 板块不存在:壳层数据随 abortReason 交给 _error 页(404 + noindex,对齐旧 Go serveNotFound)
|
||||
if (kind === 'board' && !(boot.boards ?? []).some((b) => b.id === boardId)) {
|
||||
throw render(404, {
|
||||
head: buildNotFoundHead(pathname, brand, base),
|
||||
boot,
|
||||
});
|
||||
}
|
||||
|
||||
return { kind, boot, head: buildFeedHead(boot, base) };
|
||||
}
|
||||
26
frontend/src/ssr/feedPaths.ts
Normal file
26
frontend/src/ssr/feedPaths.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SSR feed 页路由匹配(Vike +route.ts 用)。
|
||||
*
|
||||
* 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`
|
||||
* (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。
|
||||
*/
|
||||
|
||||
export type FeedSsrMatch =
|
||||
| { kind: 'home'; id: '' }
|
||||
| { kind: 'board'; id: string }
|
||||
| { kind: 'post'; id: string };
|
||||
|
||||
const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [
|
||||
{ re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) },
|
||||
{ re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) },
|
||||
// 123 与 123.html(伪静态后缀由后台配置,两种形态都接住)
|
||||
{ re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) },
|
||||
];
|
||||
|
||||
export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null {
|
||||
for (const { re, pick } of FEED_PATTERNS) {
|
||||
const m = urlPathname.match(re);
|
||||
if (m) return pick(m);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
30
frontend/src/ssr/feedSeed.ts
Normal file
30
frontend/src/ssr/feedSeed.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* SSR 数据播种(双端安全模块)。
|
||||
*
|
||||
* onRenderHtml(服务端,请求作用域内)与 onRenderClient(hydrate 前,模块单例)
|
||||
* 都会在渲染前把 +data 结果灌入缓存层;两侧均经 seedHomeBoot/setSessionSnapshot
|
||||
* 内部的 scope 判断,逻辑同一份。
|
||||
*
|
||||
* 注意:本模块禁止引入仅服务端依赖(如 api/serverClient),否则会被打进客户端包。
|
||||
*/
|
||||
import type { HomeBootPayload, PostDetailSnapshot } from '../utils/homeBoot';
|
||||
import { postDetailCacheKey, seedHomeBoot } from '../utils/homeBoot';
|
||||
import { setSessionSnapshot } from '../utils/sessionPageCache';
|
||||
import type { PageHead } from './head';
|
||||
|
||||
export type FeedData = {
|
||||
kind: 'home' | 'board' | 'post' | 'notfound';
|
||||
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
|
||||
boot?: HomeBootPayload;
|
||||
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
|
||||
snapshot?: PostDetailSnapshot;
|
||||
/** <head> 元信息(服务端专用,onRenderHtml 消费) */
|
||||
head?: PageHead;
|
||||
};
|
||||
|
||||
/** 将 +data 结果灌入当前缓存层(服务端请求作用域 / 客户端模块单例均可调用) */
|
||||
export function seedFeedData(data: FeedData | null | undefined): void {
|
||||
if (!data) return;
|
||||
if (data.boot) seedHomeBoot(data.boot);
|
||||
if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot);
|
||||
}
|
||||
363
frontend/src/ssr/head.ts
Normal file
363
frontend/src/ssr/head.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* SEO head 构建:移植 Go 侧 embed_static/spa_meta.go + handler/seo.go 的 meta 生成逻辑。
|
||||
*
|
||||
* 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>,
|
||||
* 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。
|
||||
*/
|
||||
import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types';
|
||||
import type { HomeBootPayload } from '../utils/homeBoot';
|
||||
import { normalizePermalinkExt } from '../utils/permalink';
|
||||
|
||||
export type PageHead = {
|
||||
/** 完整 <title>(已含站点名) */
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string;
|
||||
canonical?: string;
|
||||
/** 默认 website */
|
||||
ogType?: string;
|
||||
ogImage?: string;
|
||||
/** og:site_name */
|
||||
siteName?: string;
|
||||
/** 如 noindex,follow */
|
||||
robots?: string;
|
||||
/** 站点 favicon(相对或绝对 URL) */
|
||||
favicon?: string;
|
||||
/** 已序列化的 JSON-LD 对象(不含 script 标签) */
|
||||
jsonLd?: string;
|
||||
};
|
||||
|
||||
export const SITE_NAME_FALLBACK = '姜十三论坛';
|
||||
/** 与 Go seoDescMax / seoPrerenderMax 对齐 */
|
||||
const DESC_MAX = 160;
|
||||
const BODY_MAX = 4000;
|
||||
|
||||
// —— 品牌字段派生(与 Go service/settings.go 方法对齐) ——
|
||||
|
||||
export function siteNameOf(brand?: SiteBranding | null): string {
|
||||
return brand?.name?.trim() || SITE_NAME_FALLBACK;
|
||||
}
|
||||
|
||||
/** DocumentTitle:站点名 - 标语 */
|
||||
export function documentTitleOf(brand?: SiteBranding | null): string {
|
||||
const name = brand?.name?.trim() ?? '';
|
||||
const slogan = brand?.slogan?.trim() ?? '';
|
||||
if (!name) return SITE_NAME_FALLBACK;
|
||||
return slogan ? `${name} - ${slogan}` : name;
|
||||
}
|
||||
|
||||
/** MetaDescription:优先简介,其次标语 */
|
||||
export function metaDescriptionOf(brand?: SiteBranding | null): string {
|
||||
return brand?.description?.trim() || brand?.slogan?.trim() || '';
|
||||
}
|
||||
|
||||
/** DefaultShareImage:专用 OG 图 → Logo → Favicon */
|
||||
export function defaultShareImageOf(brand?: SiteBranding | null): string {
|
||||
return brand?.og_image?.trim() || brand?.logo?.trim() || brand?.favicon?.trim() || '';
|
||||
}
|
||||
|
||||
/** pageTitle:页面名 - 站点名(与 Go handler/seo.go pageTitle 对齐) */
|
||||
export function pageTitle(page: string, siteName: string): string {
|
||||
const p = page.trim();
|
||||
const s = siteName.trim();
|
||||
if (!p && !s) return SITE_NAME_FALLBACK;
|
||||
if (!p) return s;
|
||||
if (!s) return p;
|
||||
return `${p} - ${s}`;
|
||||
}
|
||||
|
||||
// —— 文本工具(与 Go service/seo.go 对齐) ——
|
||||
|
||||
/** AbsoluteURL:相对路径拼绝对 URL;base 为空或非 http(s) 时返回空串 */
|
||||
export function absoluteURL(base: string, pathOrURL: string): string {
|
||||
const path = pathOrURL.trim();
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const b = base.trim().replace(/\/+$/, '');
|
||||
if (!b || !/^https?:\/\//i.test(b)) return '';
|
||||
return b + (path.startsWith('/') ? path : `/${path}`);
|
||||
}
|
||||
|
||||
/** TruncateRunes:按 Unicode code point 截断并加省略号 */
|
||||
export function truncateRunes(s: string, max: number): string {
|
||||
const trimmed = s.trim();
|
||||
if (max <= 0 || !trimmed) return trimmed;
|
||||
const runes = Array.from(trimmed);
|
||||
if (runes.length <= max) return trimmed;
|
||||
if (max < 2) return runes.slice(0, max).join('');
|
||||
return `${runes.slice(0, max - 1).join('')}…`;
|
||||
}
|
||||
|
||||
const styleOrScriptRe = /<(style|script)[^>]*>[\s\S]*?<\/\1>/gi;
|
||||
const htmlTagRe = /<[^>]*>/g;
|
||||
|
||||
/** StripHTMLForSearch:去标签取纯文本(与 Go service/content.go 对齐) */
|
||||
export function stripHTMLForSearch(html: string): string {
|
||||
if (!html) return '';
|
||||
const text = html
|
||||
.replace(styleOrScriptRe, ' ')
|
||||
.replace(htmlTagRe, ' ')
|
||||
.replace(/ /g, ' ');
|
||||
return text.split(/\s+/).filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const imgSrcRe = /<img[^>]+src=["']([^"']+)["']/i;
|
||||
|
||||
/** FirstImageURL:正文中第一张图片的 src(忽略 data: 内联图) */
|
||||
export function firstImageURL(html: string): string {
|
||||
const m = html.match(imgSrcRe);
|
||||
const src = m?.[1]?.trim() ?? '';
|
||||
if (!src || src.startsWith('data:')) return '';
|
||||
return src;
|
||||
}
|
||||
|
||||
/** DisplayName:昵称优先,其次用户名 */
|
||||
export function displayNameOf(user?: User | null): string {
|
||||
return user?.nickname?.trim() || user?.username?.trim() || '';
|
||||
}
|
||||
|
||||
function joinKeywords(...parts: Array<string | undefined | null>): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
for (const raw of part.replace(/[,、;;]/g, ',').split(',')) {
|
||||
const p = raw.trim();
|
||||
if (!p || seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out.join(',');
|
||||
}
|
||||
|
||||
// —— 伪静态路径(显式传入 limits,避免读缓存) ——
|
||||
|
||||
type PermOpts = { permalink_enabled?: boolean; permalink_ext?: string };
|
||||
|
||||
function permOpts(limits?: ForumLimitsPublic | null): PermOpts {
|
||||
return { permalink_enabled: !!limits?.permalink_enabled, permalink_ext: limits?.permalink_ext ?? 'html' };
|
||||
}
|
||||
|
||||
function suffixOf(limits?: ForumLimitsPublic | null): string {
|
||||
if (!limits?.permalink_enabled) return '';
|
||||
return `.${normalizePermalinkExt(limits.permalink_ext)}`;
|
||||
}
|
||||
|
||||
function feedPostPath(id: number, limits?: ForumLimitsPublic | null): string {
|
||||
return `/post/${id}${suffixOf(limits)}`;
|
||||
}
|
||||
|
||||
function feedBoardPath(id: number, limits?: ForumLimitsPublic | null): string {
|
||||
return `/board/${id}${suffixOf(limits)}`;
|
||||
}
|
||||
|
||||
function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string {
|
||||
return `/user/${id}${suffixOf(limits)}`;
|
||||
}
|
||||
|
||||
function toISO(time?: string): string {
|
||||
if (!time) return '';
|
||||
const d = new Date(time);
|
||||
return Number.isNaN(d.getTime()) ? time : d.toISOString();
|
||||
}
|
||||
|
||||
// —— head 构建 ——
|
||||
|
||||
/** 首页/板块页 head(移植 Go buildSPAPageMeta 首页分支 + ServePublicSPA 板块分支) */
|
||||
export function buildFeedHead(boot: HomeBootPayload, base: string): PageHead {
|
||||
const brand = boot.branding ?? null;
|
||||
const siteName = siteNameOf(brand);
|
||||
const siteKeywords = brand?.keywords?.trim() ?? '';
|
||||
const ogImage = defaultShareImageOf(brand);
|
||||
const limits = boot.limits ?? null;
|
||||
|
||||
const board = boot.board_id > 0
|
||||
? (boot.boards ?? []).find((b) => b.id === boot.board_id) ?? null
|
||||
: null;
|
||||
if (board) {
|
||||
const desc = truncateRunes(board.description?.trim() || metaDescriptionOf(brand), DESC_MAX);
|
||||
return {
|
||||
title: pageTitle(board.name, siteName),
|
||||
description: desc,
|
||||
keywords: joinKeywords(board.name, siteKeywords),
|
||||
canonical: absoluteURL(base, feedBoardPath(board.id, limits)) || undefined,
|
||||
ogType: 'website',
|
||||
ogImage: ogImage || undefined,
|
||||
siteName,
|
||||
favicon: brand?.favicon?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 站点首页(含搜索/标签/作者筛选:与旧 Go 一致 canonical 指向 /)
|
||||
const desc = truncateRunes(metaDescriptionOf(brand), DESC_MAX);
|
||||
const homeURL = absoluteURL(base, '/');
|
||||
const jsonLd = homeURL
|
||||
? JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: siteName,
|
||||
description: desc,
|
||||
url: homeURL,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
title: documentTitleOf(brand),
|
||||
description: desc,
|
||||
keywords: siteKeywords || undefined,
|
||||
canonical: homeURL || undefined,
|
||||
ogType: 'website',
|
||||
ogImage: ogImage || undefined,
|
||||
siteName,
|
||||
favicon: brand?.favicon?.trim() || undefined,
|
||||
jsonLd,
|
||||
};
|
||||
}
|
||||
|
||||
/** 帖子详情页 head(移植 Go postPageMeta) */
|
||||
export function buildPostHead(
|
||||
post: PostItem,
|
||||
brand: SiteBranding | null,
|
||||
limits: ForumLimitsPublic | null,
|
||||
base: string,
|
||||
): PageHead {
|
||||
const siteName = siteNameOf(brand);
|
||||
const content = post.content ?? '';
|
||||
const plain = stripHTMLForSearch(content);
|
||||
const desc = truncateRunes(plain, DESC_MAX);
|
||||
const canonical = absoluteURL(base, feedPostPath(post.id, limits));
|
||||
const defImage = defaultShareImageOf(brand);
|
||||
|
||||
let ogImage = absoluteURL(base, firstImageURL(content));
|
||||
if (!ogImage) ogImage = absoluteURL(base, post.user?.avatar ?? '');
|
||||
if (!ogImage) ogImage = defImage;
|
||||
|
||||
const jsonLd: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'DiscussionForumPosting',
|
||||
headline: post.title,
|
||||
description: desc,
|
||||
datePublished: toISO(post.created_at),
|
||||
dateModified: toISO(post.updated_at || post.created_at),
|
||||
url: canonical,
|
||||
mainEntityOfPage: canonical,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: displayNameOf(post.user),
|
||||
url: absoluteURL(base, feedUserPath(post.user_id, limits)),
|
||||
},
|
||||
interactionStatistic: {
|
||||
'@type': 'InteractionCounter',
|
||||
interactionType: 'https://schema.org/ViewAction',
|
||||
userInteractionCount: post.view_count ?? 0,
|
||||
},
|
||||
};
|
||||
if (post.board?.name) jsonLd.articleSection = post.board.name;
|
||||
if (ogImage) jsonLd.image = [ogImage];
|
||||
const body = truncateRunes(plain, BODY_MAX);
|
||||
if (body) jsonLd.articleBody = body;
|
||||
|
||||
return {
|
||||
title: pageTitle(post.title, siteName),
|
||||
description: desc,
|
||||
canonical: canonical || undefined,
|
||||
ogType: 'article',
|
||||
ogImage: ogImage || undefined,
|
||||
siteName,
|
||||
jsonLd: JSON.stringify(jsonLd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 404 页 head(移植 Go notFoundPageMeta) */
|
||||
export function buildNotFoundHead(
|
||||
pathname: string,
|
||||
brand?: SiteBranding | null,
|
||||
base = '',
|
||||
): PageHead {
|
||||
return {
|
||||
title: pageTitle('页面不存在', siteNameOf(brand)),
|
||||
description: '您访问的页面不存在或已删除',
|
||||
canonical: absoluteURL(base, pathname) || undefined,
|
||||
ogType: 'website',
|
||||
robots: 'noindex,follow',
|
||||
siteName: siteNameOf(brand),
|
||||
};
|
||||
}
|
||||
|
||||
// —— HTML 渲染 ——
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function twitterCard(ogImage?: string): string {
|
||||
return ogImage?.trim() ? 'summary_large_image' : 'summary';
|
||||
}
|
||||
|
||||
function firstNonEmpty(...vals: Array<string | undefined>): string {
|
||||
for (const v of vals) {
|
||||
const s = v?.trim();
|
||||
if (s) return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 <head> 内的 SEO 节点(title / meta / OG / Twitter / JSON-LD / favicon)。
|
||||
* head 缺失时回退旧 SPA 入口的默认标题;返回值为自转义 HTML,调用方需经
|
||||
* dangerouslySkipEscape 注入。
|
||||
*/
|
||||
export function renderHeadTags(head?: PageHead | null): string {
|
||||
const h = head ?? null;
|
||||
const title = firstNonEmpty(h?.title, `${SITE_NAME_FALLBACK} - 拾三一隅,自在交流`);
|
||||
const description = h?.description?.trim() ?? '';
|
||||
const siteName = firstNonEmpty(h?.siteName, SITE_NAME_FALLBACK);
|
||||
const ogType = firstNonEmpty(h?.ogType, 'website');
|
||||
const ogImage = h?.ogImage?.trim() ?? '';
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<title>${esc(title)}</title>`);
|
||||
if (description) parts.push(`<meta name="description" content="${esc(description)}"/>`);
|
||||
const keywords = h?.keywords?.trim() ?? '';
|
||||
if (keywords) parts.push(`<meta name="keywords" content="${esc(keywords)}"/>`);
|
||||
const canonical = h?.canonical?.trim() ?? '';
|
||||
if (canonical) parts.push(`<link rel="canonical" href="${esc(canonical)}"/>`);
|
||||
const favicon = h?.favicon?.trim() ?? '';
|
||||
if (favicon) {
|
||||
parts.push(`<link rel="icon" href="${esc(favicon)}"/>`);
|
||||
parts.push(`<link rel="shortcut icon" href="${esc(favicon)}"/>`);
|
||||
}
|
||||
const robots = h?.robots?.trim() ?? '';
|
||||
if (robots) parts.push(`<meta name="robots" content="${esc(robots)}"/>`);
|
||||
|
||||
const prop = (p: string, content: string) => {
|
||||
if (content) parts.push(`<meta property="${esc(p)}" content="${esc(content)}"/>`);
|
||||
};
|
||||
const metaName = (name: string, content: string) => {
|
||||
if (content) parts.push(`<meta name="${esc(name)}" content="${esc(content)}"/>`);
|
||||
};
|
||||
|
||||
prop('og:type', ogType);
|
||||
prop('og:site_name', siteName);
|
||||
prop('og:locale', 'zh_CN');
|
||||
prop('og:title', title);
|
||||
prop('og:description', description);
|
||||
prop('og:url', canonical);
|
||||
prop('og:image', ogImage);
|
||||
metaName('twitter:card', twitterCard(ogImage));
|
||||
metaName('twitter:title', title);
|
||||
metaName('twitter:description', description);
|
||||
metaName('twitter:image', ogImage);
|
||||
|
||||
const jsonLd = h?.jsonLd?.trim() ?? '';
|
||||
if (jsonLd) {
|
||||
// 仅转义 < 防止提前闭合 script(与 Go 侧处理一致)
|
||||
parts.push(`<script type="application/ld+json">${jsonLd.replace(/</g, '\\u003c')}</script>`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user