14 Commits

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

View File

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

5
.gitignore vendored
View File

@@ -8,11 +8,12 @@
/node_modules/ /node_modules/
/frontend/node_modules/ /frontend/node_modules/
/frontend/dist/ /frontend/dist/
/embed_static/static/spa/ /.vite/
# Go 编译产物 # Go 编译产物
/dist/ /dist/
*.exe *.exe
/jiang13-*
# 临时文件 # 临时文件
tmp-cookie.txt tmp-cookie.txt
@@ -27,5 +28,3 @@ tmp-cookie.txt
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
frontend/tmp-lucide-paths.json

View File

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

View File

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

View File

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

View File

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

140
README.md
View File

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

View File

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

View File

@@ -98,7 +98,11 @@ func (p *program) setup() error {
} }
log.Printf("姜十三论坛已启动: http://localhost%s", addr) log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) if cfg.DevMode {
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
} else {
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
}
log.Printf("工作目录: %s", cfg.WorkPath) log.Printf("工作目录: %s", cfg.WorkPath)
log.Printf("配置文件: %s", cfg.ConfigFile) log.Printf("配置文件: %s", cfg.ConfigFile)
log.Printf("数据目录: %s", cfg.DataDir) log.Printf("数据目录: %s", cfg.DataDir)

View File

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

View File

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

View File

@@ -28,6 +28,14 @@
访问路径:`/page/changelog`。 访问路径:`/page/changelog`。
## 部署指南(置顶帖)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
## 可选短文 ## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md): 若站务区希望不止一篇,见 [optional.md](./optional.md):

View File

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

View File

@@ -1,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
}

View File

@@ -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())
}

View File

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

View File

@@ -1,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>

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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
View File

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

View File

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

View File

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

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

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

View File

@@ -154,12 +154,12 @@ export const api = {
}, },
adminApprovePost: (id: number) => adminApprovePost: (id: number) =>
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }), request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
adminPinPost: (id: number, pinned: boolean) => adminPinPost: (id: number, pinned: number) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, { request<{ message: string; pinned: number }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }), method: 'POST', body: JSON.stringify({ pinned }),
}), }),
adminBoardPinPost: (id: number, boardPinned: boolean) => adminBoardPinPost: (id: number, boardPinned: number) =>
request<{ message: string; board_pinned: boolean }>(`/api/admin/posts/${id}/board-pin`, { request<{ message: string; board_pinned: number }>(`/api/admin/posts/${id}/board-pin`, {
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }), method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
}), }),
adminFeaturePost: (id: number, featured: boolean) => adminFeaturePost: (id: number, featured: boolean) =>
@@ -264,8 +264,6 @@ export const api = {
}), }),
postRevisions: (id: number) => postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`), request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }), adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminTrashPosts: (params: { page?: number; keyword?: string }) => { adminTrashPosts: (params: { page?: number; keyword?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
@@ -582,7 +580,6 @@ export const api = {
`/api/users/search?${sp}`, `/api/users/search?${sp}`,
); );
}, },
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
logout: () => request('/api/logout', { method: 'POST' }), logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }), like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }), likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),

View File

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

View File

@@ -83,7 +83,12 @@ export interface PostItem {
board_id: number; board_id: number;
user_id: number; user_id: number;
title: string; title: string;
/** 完整正文;列表接口不下发(为空),仅帖子详情等场景有值 */
content?: string; content?: string;
/** 列表接口预算的纯文本摘要(60 字),列表页直接使用无需解析 content */
excerpt?: string;
/** 列表接口预算的正文首图 URL(缩略图模式用) */
first_image?: string;
tags: string; tags: string;
/** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */ /** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */
post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string; post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string;
@@ -94,9 +99,9 @@ export interface PostItem {
bounty_comment_id?: number; bounty_comment_id?: number;
lottery_winner_count?: number; lottery_winner_count?: number;
lottery_status?: 'open' | 'drawn' | string; lottery_status?: 'open' | 'drawn' | string;
pinned: boolean; pinned: number;
/** 板块内置顶(仅板块列表抬升,首页不抬升) */ /** 板块内置顶(仅板块列表抬升,首页不抬升) */
board_pinned?: boolean; board_pinned?: number;
featured?: boolean; featured?: boolean;
edit_locked?: boolean; edit_locked?: boolean;
/** 禁止新评论(结贴) */ /** 禁止新评论(结贴) */

View File

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

View File

@@ -9,7 +9,7 @@ import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import { TableKit } from '@tiptap/extension-table'; import { TableKit } from '@tiptap/extension-table';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole, List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
@@ -121,7 +121,7 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
/** 净化编辑器 HTML,保留 members-only 自定义标签 */ /** 净化编辑器 HTML,保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */ /** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
@@ -268,13 +268,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
ImageGroup, ImageGroup,
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') { // prosemirror 未公开 parent 字段,这里仅读父节点类型名
const parentName = (node as unknown as { parent?: { type?: { name?: string } } })
.parent?.type?.name;
if (node.type.name === 'paragraph' && parentName === 'membersOnly') {
return MEMBERS_ONLY_PLACEHOLDER; return MEMBERS_ONLY_PLACEHOLDER;
} }
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') { if (node.type.name === 'paragraph' && parentName === 'replyOnly') {
return REPLY_ONLY_PLACEHOLDER; return REPLY_ONLY_PLACEHOLDER;
} }
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') { if (node.type.name === 'paragraph' && parentName === 'pointsOnly') {
return '此处内容需积分解锁后可见…'; return '此处内容需积分解锁后可见…';
} }
return placeholder; return placeholder;

View File

@@ -1,5 +1,5 @@
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react'; import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { useCheckIn } from '../hooks/useCheckIn'; import { useCheckIn } from '../hooks/useCheckIn';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react'; import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { api } from '../api/client'; import { api } from '../api/client';
import { renderCommentContent } from '../utils/content'; import { renderCommentContent } from '../utils/content';
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks'; import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';

View File

@@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Bold, Italic, Underline as UnderlineIcon, Strikethrough,
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon, List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
@@ -36,7 +36,7 @@ interface Props {
} }
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
function isEditorEmpty(editor: Editor): boolean { function isEditorEmpty(editor: Editor): boolean {
@@ -148,7 +148,9 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value, getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value,
isEmpty: () => editor ? isEditorEmpty(editor) : !value.trim(), isEmpty: () => editor ? isEditorEmpty(editor) : !value.trim(),
focus: (options?: { preventScroll?: boolean }) => { editor?.commands.focus(options); }, focus: (options?: { preventScroll?: boolean }) => {
editor?.commands.focus(undefined, { scrollIntoView: !options?.preventScroll });
},
}), [editor, value]); }), [editor, value]);
const insertSticker = useCallback((sticker: Sticker) => { const insertSticker = useCallback((sticker: Sticker) => {

View File

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

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router';
import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink'; import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits'; import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react'; import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';

View File

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

View File

@@ -1,5 +1,5 @@
import { useMemo, useCallback, useState } from 'react'; import { useMemo, useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router';
import { renderPostContentHtml } from '../utils/postContent'; import { renderPostContentHtml } from '../utils/postContent';
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks'; import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
import { loginPath, registerPath } from '../utils/authRedirect'; import { loginPath, registerPath } from '../utils/authRedirect';

View File

@@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { MessageCircle } from 'lucide-react'; import { MessageCircle } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge'; import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon'; import FeaturedIcon from '@/components/FeaturedIcon';
@@ -39,9 +39,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName); const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
const commentCount = post.comment_count ?? 0; const commentCount = post.comment_count ?? 0;
const href = postPath(post.id); const href = postPath(post.id);
const firstImage = firstImageFromHTML(post.content || ''); // 列表接口已预算摘要/首图且不下发正文;content 仅在详情等非列表场景有值时兜底
const firstImage = post.first_image || firstImageFromHTML(post.content || '');
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null; const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : ''; const excerpt = showExcerpt ? (post.excerpt || excerptFromHTML(post.content || '', 60)) : '';
const showBoardBadge = !!post.board && boardId !== post.board.id; const showBoardBadge = !!post.board && boardId !== post.board.id;
const openPost = () => onSelect(post.id); const openPost = () => onSelect(post.id);
@@ -71,10 +72,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
const titleRow = ( const titleRow = (
<div className="post-title-row"> <div className="post-title-row">
{post.pinned && ( {post.pinned > 0 && (
<span className="post-pin-badge" title="全局置顶">全局置顶</span> <span className="post-pin-badge" title="全局置顶">全局置顶</span>
)} )}
{post.board_pinned && ( {(post.board_pinned ?? 0) > 0 && (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span> <span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
)} )}
{post.featured && ( {post.featured && (

View File

@@ -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;
}
}

View File

@@ -151,11 +151,11 @@ export default function PostManageMenu({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={onPin}> <DropdownMenuItem onSelect={onPin}>
<Pin size={14} aria-hidden /> <Pin size={14} aria-hidden />
{post.pinned ? '取消全局置顶' : '全局置顶'} {post.pinned > 0 ? '取消全局置顶' : '全局置顶'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={onBoardPin}> <DropdownMenuItem onSelect={onBoardPin}>
<Pin size={14} aria-hidden /> <Pin size={14} aria-hidden />
{post.board_pinned ? '取消板块置顶' : '板块置顶'} {(post.board_pinned ?? 0) > 0 ? '取消板块置顶' : '板块置顶'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -21,6 +21,12 @@ function pickScrollEl(): HTMLElement | null {
return null; return null;
} }
// Feed 文档滚动模式(.app-shell--doc-scroll):滚动根是 document(html)。
// 触摸事件会冒泡到 html,scrollTop 即窗口滚动量。
if (document.querySelector('.app-shell--doc-scroll')) {
return document.scrollingElement as HTMLElement | null;
}
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走) // 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll'); const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
if (mobileFeed) return mobileFeed; if (mobileFeed) return mobileFeed;

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react'; import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom'; import { useLocation, useSearchParams, useNavigate } from 'react-router';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types'; import type { AsideWidget, RecentComment, RecentUser, TagCount, User, UserActivityStats, UserPublic, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime, formatTime } from '../utils/content'; import { formatShortDateTime, formatTime } from '../utils/content';
@@ -20,6 +20,9 @@ export type PostDetailAside = {
headings: PostHeading[]; headings: PostHeading[];
scrollRoot?: HTMLElement | null; scrollRoot?: HTMLElement | null;
outlineTitle?: string; outlineTitle?: string;
/** SSR/预热快照携带的作者主页资料与统计(首帧直接渲染,缺省卡片自行拉取) */
authorProfile?: UserPublic | null;
authorStats?: UserActivityStats | null;
}; };
interface Props { interface Props {
@@ -237,6 +240,8 @@ export default function RightPanel({
author={postDetail.author} author={postDetail.author}
publishedAt={postDetail.publishedAt} publishedAt={postDetail.publishedAt}
viewCount={postDetail.viewCount} viewCount={postDetail.viewCount}
initialProfile={postDetail.authorProfile ?? null}
initialStats={postDetail.authorStats ?? null}
/> />
) : null} ) : null}
<div className="widget-card widget-card--outline"> <div className="widget-card widget-card--outline">

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Globe2 } from 'lucide-react'; import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { api } from '../api/client'; import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types'; import type { CommunityShowcaseItem } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';

View File

@@ -1,7 +1,7 @@
import { import {
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2, Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useLocation, useSearchParams, Link } from 'react-router';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';

View File

@@ -1,6 +1,6 @@
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMediaQuery } from '../hooks/useTheme'; import { useMediaQuery } from '../hooks/useTheme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router';
import { useSitePages } from '../hooks/useSitePages'; import { useSitePages } from '../hooks/useSitePages';
import { pagePath } from '../utils/permalink'; import { pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';

View File

@@ -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(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return t;
}
function truncateRunes(s: string, max: number): string {
const chars = Array.from(s);
if (chars.length <= max) return s;
return chars.slice(0, max).join('');
}
function firstImageSrc(html: string): string {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
const src = m?.[1]?.trim() || '';
if (!src || src.startsWith('data:')) return '';
return src;
}
function displayName(post: PostItem): string {
const n = post.user?.nickname?.trim() || post.user?.username?.trim();
return n || '用户';
}
type Props = {
posts: PostItem[];
sort: FeedSort;
boardId: number;
};
/** 与 Go writeSSRPostRow 同构的静态列表(hydrate 首帧专用) */
export default function StaticFeedList({ posts, sort, boardId }: Props) {
const { limits } = useForumLimits();
const style = limits.feed_list_style ?? 'title';
const titleOnly = style === 'title';
const needExcerpt = style === 'excerpt' || style === 'thumbnail';
const needThumb = style === 'thumbnail';
return (
<div className="post-list-scroll post-list-scroll--ssr">
<div className="content-surface content-surface--ssr">
{posts.length === 0 ? (
<div className="feed-empty">暂无帖子</div>
) : (
posts.map((post) => {
const href = postPath(post.id, limits);
const author = displayName(post);
const initial = Array.from(author)[0] || '?';
let excerpt = '';
let thumb = '';
if (needExcerpt || needThumb) {
excerpt = truncateRunes(stripHtmlPlain(post.content || ''), 120);
}
if (needThumb) thumb = firstImageSrc(post.content || '');
const rowClass = [
'post-row',
'post-row--v2',
titleOnly ? 'post-row--title-only' : '',
thumb ? 'post-row--has-thumb' : '',
].filter(Boolean).join(' ');
let timeLabel = formatTime(post.created_at);
if (sort === 'reply' && !post.last_reply_at) {
timeLabel = '暂无回复';
}
const lastReplyName = post.last_reply_user?.nickname?.trim()
|| post.last_reply_user?.username?.trim()
|| post.last_reply_guest_nick?.trim()
|| '';
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
const commentCount = post.comment_count ?? 0;
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned ? (
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
) : null}
{post.board_pinned ? (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
) : null}
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
{post.status === 'pending' ? (
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
) : null}
{post.status === 'rejected' ? (
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
) : null}
<span className="post-title">{post.title}</span>
{hasTypeBadge ? (
<span className="post-title-type-badges">
{post.post_type === 'question' ? (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
) : null}
{post.post_type === 'poll' ? (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 ? (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">
悬赏 {post.bounty_points}
</span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' ? (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
) : null}
{post.post_type === 'lottery' ? (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
) : null}
</span>
) : null}
</div>
);
const metaLeft = (
<div className="post-meta-left">
{boardId === 0 && post.board?.id ? (
<span className="post-list-board-btn">
<span
className={`post-list-board-badge board-badge board-badge--${getBoardThemeIndex(post.board)}`}
>
{post.board.name}
</span>
</span>
) : null}
<span className="post-meta-author">{author}</span>
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
<span className="post-meta-time post-meta-time--created">{timeLabel}</span>
{showLastReply ? (
<span className="post-meta-last-reply">
<span className="post-meta-last-reply-arrow" aria-hidden>←</span>
{/* 外层是 <a class="post-row">,禁止嵌套 a */}
<span className="post-meta-last-reply-user">{lastReplyName}</span>
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
</span>
) : null}
</div>
);
const stats = (
<div className="post-stats">
<span
className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}
title="评论"
>
<MessageCircle aria-hidden />
{commentCount}
</span>
</div>
);
return (
<a key={post.id} className={rowClass} href={href}>
{post.user?.avatar ? (
<span className="post-avatar user-link--avatar-only">
<img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
</span>
) : (
<span className="post-avatar user-link--avatar-only">{initial}</span>
)}
{thumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
<div className="post-meta post-meta--inline">
{metaLeft}
{stats}
</div>
</div>
<div className="post-aside">
<div className="post-thumb" aria-hidden>
<img src={thumb} alt="" loading="lazy" decoding="async" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
</div>
<div className="post-meta">
{metaLeft}
{stats}
</div>
</div>
)}
</a>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import type { TagCount } from '../api/types'; import type { TagCount } from '../api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';

View File

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

View File

@@ -1,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>
);
}

View File

@@ -36,9 +36,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
// 有 SSR 种子时仍后台校验;无种子则首屏拉取 // SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求;
// 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动 // 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
useEffect(() => { void refresh(); }, [refresh]); useEffect(() => {
if (peekAuthSeed().hasSeed) return;
void refresh();
}, [refresh]);
const prevUserId = useRef<number | null | 'init'>('init'); const prevUserId = useRef<number | null | 'init'>('init');
useEffect(() => { useEffect(() => {

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types'; import type { ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types'; import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
import { getScope } from '../ssr/scope';
const DEFAULT_LIMITS: ForumLimitsPublic = { const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128, post_title_max: 128,
@@ -38,6 +39,11 @@ let inflight: Promise<ForumLimitsPublic> | null = null;
let cacheEpoch = 0; let cacheEpoch = 0;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** 当前生效的论坛限制:SSR 读请求作用域,客户端读模块缓存 */
function currentLimits(): ForumLimitsPublic | null {
return getScope()?.limits ?? cached;
}
function fetchLimits(): Promise<ForumLimitsPublic> { function fetchLimits(): Promise<ForumLimitsPublic> {
if (cached) return Promise.resolve(cached); if (cached) return Promise.resolve(cached);
if (inflight) return inflight; if (inflight) return inflight;
@@ -53,8 +59,9 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
/** 获取前台可见的论坛限制配置 */ /** 获取前台可见的论坛限制配置 */
export function useForumLimits() { export function useForumLimits() {
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS); const [limits, setLimits] = useState<ForumLimitsPublic>(currentLimits() ?? DEFAULT_LIMITS);
const [loading, setLoading] = useState(!cached); const [loading, setLoading] = useState(!currentLimits());
const [epoch, setEpoch] = useState(cacheEpoch); const [epoch, setEpoch] = useState(cacheEpoch);
useEffect(() => { useEffect(() => {
@@ -94,8 +101,13 @@ export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promi
return limits; return limits;
} }
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */ /** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */
export function seedForumLimitsCache(limits: ForumLimitsPublic) { export function seedForumLimitsCache(limits: ForumLimitsPublic) {
const scope = getScope();
if (scope) {
scope.limits = limits;
return;
}
cached = limits; cached = limits;
cacheEpoch += 1; cacheEpoch += 1;
listeners.forEach(fn => fn()); listeners.forEach(fn => fn());
@@ -110,5 +122,5 @@ export function invalidateForumLimitsCache() {
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */ /** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
export function getCachedForumLimits(): ForumLimitsPublic { export function getCachedForumLimits(): ForumLimitsPublic {
return cached ?? DEFAULT_LIMITS; return currentLimits() ?? DEFAULT_LIMITS;
} }

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router';
import { api } from '../api/client'; import { api } from '../api/client';
import { useForumLimits } from './useForumLimits'; import { useForumLimits } from './useForumLimits';

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { buildHomeUrl } from '../components/FeedSortBar'; import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache'; import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition'; import { transitionTo } from '../utils/spaTransition';

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { SiteBranding } from '../api/types'; import type { SiteBranding } from '../api/types';
import { getScope } from '../ssr/scope';
export const DEFAULT_BRANDING: SiteBranding = { export const DEFAULT_BRANDING: SiteBranding = {
name: '姜十三论坛', name: '姜十三论坛',
@@ -30,8 +31,8 @@ export function siteMetaDescription(brand: SiteBranding): string {
return brand.slogan?.trim() ?? ''; return brand.slogan?.trim() ?? '';
} }
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
function readBootBranding(): SiteBranding | null { function readBootBranding(): SiteBranding | null {
if (typeof window === 'undefined') return null;
try { try {
const boot = window.__J13_BRANDING__; const boot = window.__J13_BRANDING__;
if (!boot || typeof boot !== 'object') return null; if (!boot || typeof boot !== 'object') return null;
@@ -50,13 +51,20 @@ let cacheEpoch = 0;
let preferCacheOnce = false; let preferCacheOnce = false;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */
function currentBranding(): SiteBranding | null {
return getScope()?.branding ?? cached;
}
function fetchBranding(): Promise<SiteBranding> { function fetchBranding(): Promise<SiteBranding> {
if (preferCacheOnce && cached) { if (preferCacheOnce && cached) {
preferCacheOnce = false; preferCacheOnce = false;
return Promise.resolve(cached); return Promise.resolve(cached);
} }
// SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求;
// 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发
if (cached) return Promise.resolve(cached);
if (inflight) return inflight; if (inflight) return inflight;
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
inflight = api.siteBranding() inflight = api.siteBranding()
.then(b => { .then(b => {
cached = { ...DEFAULT_BRANDING, ...b }; cached = { ...DEFAULT_BRANDING, ...b };
@@ -96,13 +104,13 @@ function applyDocumentBrand(brand: SiteBranding) {
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */ /** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
export function getCachedSiteBranding(): SiteBranding { export function getCachedSiteBranding(): SiteBranding {
return cached ?? DEFAULT_BRANDING; return currentBranding() ?? DEFAULT_BRANDING;
} }
/** 获取站点品牌配置(名称、Logo 等) */ /** 获取站点品牌配置(名称、Logo 等) */
export function useSiteBranding() { export function useSiteBranding() {
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING); const [branding, setBranding] = useState<SiteBranding>(currentBranding() ?? DEFAULT_BRANDING);
const [loading, setLoading] = useState(!cached); const [loading, setLoading] = useState(!currentBranding());
const [epoch, setEpoch] = useState(cacheEpoch); const [epoch, setEpoch] = useState(cacheEpoch);
useEffect(() => { useEffect(() => {
@@ -131,6 +139,7 @@ export function useSiteBranding() {
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */ /** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
export function refetchSiteBranding() { export function refetchSiteBranding() {
cached = null;
inflight = null; inflight = null;
cacheEpoch += 1; cacheEpoch += 1;
listeners.forEach(fn => fn()); listeners.forEach(fn => fn());
@@ -140,6 +149,7 @@ export function refetchSiteBranding() {
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> { export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
if (!opts?.force && cached) return cached; if (!opts?.force && cached) return cached;
if (opts?.force) { if (opts?.force) {
cached = null;
inflight = null; inflight = null;
preferCacheOnce = false; preferCacheOnce = false;
} }
@@ -160,9 +170,15 @@ export function invalidateSiteBrandingCache() {
listeners.forEach(fn => fn()); listeners.forEach(fn => fn());
} }
/** 用管理端刚保存的值立即更新缓存与文档标题 */ /** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */
export function seedSiteBrandingCache(brand: SiteBranding) { export function seedSiteBrandingCache(brand: SiteBranding) {
cached = { ...DEFAULT_BRANDING, ...brand }; const merged: SiteBranding = { ...DEFAULT_BRANDING, ...brand };
const scope = getScope();
if (scope) {
scope.branding = merged;
return;
}
cached = merged;
applyDocumentBrand(cached); applyDocumentBrand(cached);
cacheEpoch += 1; cacheEpoch += 1;
listeners.forEach(fn => fn()); listeners.forEach(fn => fn());

View File

@@ -1,19 +1,26 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { SitePageSummary } from '../api/types'; import type { SitePageSummary } from '../api/types';
import { getScope } from '../ssr/scope';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
let cache: SitePageSummary[] | null = null; let cache: SitePageSummary[] | null = null;
let pending: Promise<SitePageSummary[]> | null = null; let pending: Promise<SitePageSummary[]> | null = null;
/** 当前生效的站点页摘要:SSR 读请求作用域,客户端读模块缓存 */
function currentPages(): SitePageSummary[] | null {
return getScope()?.pages ?? cache;
}
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */ /** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> { export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
const cur = currentPages();
if (opts?.force) { if (opts?.force) {
cache = null; cache = null;
pending = null; pending = null;
} }
if (cache) return Promise.resolve(cache); if (cur) return Promise.resolve(cur);
if (!pending) { if (!pending) {
pending = api.pages() pending = api.pages()
.then(d => { .then(d => {
@@ -31,7 +38,7 @@ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SiteP
/** 已发布单页摘要(页脚/侧栏导航) */ /** 已发布单页摘要(页脚/侧栏导航) */
export function useSitePages() { export function useSitePages() {
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []); const [pages, setPages] = useState<SitePageSummary[]>(currentPages() ?? []);
useEffect(() => { useEffect(() => {
const load = () => { const load = () => {
@@ -68,8 +75,14 @@ export function invalidateSitePagesCache() {
cache = null; cache = null;
} }
/** 文档 SSR:同步写入站点页摘要缓存 */ /** 文档 SSR:同步写入站点页摘要缓存(SSR 时只写请求作用域) */
export function seedSitePagesCache(pages: SitePageSummary[]) { export function seedSitePagesCache(pages: SitePageSummary[]) {
cache = Array.isArray(pages) ? pages : []; const list = Array.isArray(pages) ? pages : [];
const scope = getScope();
if (scope) {
scope.pages = list;
return;
}
cache = list;
pending = null; pending = null;
} }

View File

@@ -23,13 +23,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
export const useTheme = () => useContext(ThemeContext); export const useTheme = () => useContext(ThemeContext);
/**
* 媒体查询 Hook(hydration 安全)。
*
* SSR 永远输出桌面三栏布局,因此服务端与 hydrate 首帧统一返回 false,
* 保证首帧 React 树与服务端 DOM 完全同构(窄屏若首帧就读 matchMedia,
* 会与 SSR 桌面布局不一致,导致 React 丢弃整棵 SSR DOM 全量重建)。
* hydration 提交后的 layout effect 在首帧绘制前同步真实值并监听变化,
* 窄屏用户无闪烁地切换到移动布局。
*/
export function useMediaQuery(query: string) { export function useMediaQuery(query: string) {
const [match, setMatch] = useState(() => window.matchMedia(query).matches); const [match, setMatch] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
const m = window.matchMedia(query); const m = window.matchMedia(query);
const fn = () => setMatch(m.matches); const sync = () => setMatch(m.matches);
m.addEventListener('change', fn); sync();
return () => m.removeEventListener('change', fn); m.addEventListener('change', sync);
return () => m.removeEventListener('change', sync);
}, [query]); }, [query]);
return match; return match;
} }

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom'; import { useBlocker } from 'react-router';
interface Options { interface Options {
/** 是否存在未保存的修改 */ /** 是否存在未保存的修改 */

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router';
import { import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity, LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
} from 'lucide-react'; } from 'lucide-react';

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react'; import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,13 +12,16 @@ import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types'; import type { Board, RecentComment, RecentUser, ForumStats, SitePage, TagCount, User, UserActivityStats, UserPublic } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
import { renderPostContentHtml } from '../utils/postContent';
import { getSessionSnapshot } from '../utils/sessionPageCache';
import { postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache'; import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel'; import RightPanel from '../components/RightPanel';
import BackToTop from '../components/BackToTop'; import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits'; import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets'; import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
@@ -44,28 +47,25 @@ import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink'; import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages'; import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate'; import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
import { getBootUnread } from '../utils/authBoot'; import { getBootUnread, peekAuthSeed } from '../utils/authBoot';
export default function MainLayout() { export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
useMonitorPageview(); useMonitorPageview();
const mqMobile = useMediaQuery('(max-width: 768px)'); // 响应式分支首帧恒为桌面(useMediaQuery hydration 安全),layout effect 绘制前同步真实值
const isMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)'); const hideAside = useMediaQuery('(max-width: 1100px)');
/** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
const isMobile = forceDesktop ? false : mqMobile;
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname); const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
/** hydrate 首帧提交后清除同构标志(shellReady/冷启动门闩在首帧渲染期读取) */
useEffect(() => { useEffect(() => {
if (!forceDesktop) return;
endHomeHydrate(); endHomeHydrate();
startTransition(() => setForceDesktop(false)); }, []);
}, [forceDesktop]);
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards()); const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats()); const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
@@ -76,14 +76,54 @@ export default function MainLayout() {
const [notifyUnread, setNotifyUnread] = useState(0); const [notifyUnread, setNotifyUnread] = useState(0);
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags()); const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
const [postOutline, setPostOutline] = useState<{ // SSR / hydrate 首帧:详情页快照已在渲染前 seed(onRenderHtml / onRenderClient),
headings: PostHeading[]; // 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行,
scrollRoot: HTMLElement | null; // 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。
title?: string; const [postOutline, setPostOutline] = useState<PostOutlineState>(() => {
author?: User | null; const openLinks = getCachedForumLimits()?.open_content_links_in_new_tab;
publishedAt?: string; // 帖子详情:从 post 快照派生作者卡 + 目录
viewCount?: number; const postM = loc.pathname.match(/^\/post\/(\d+)/);
} | null>(null); if (postM && !/\/edit$/.test(loc.pathname)) {
const id = Number(postM[1]);
if (id) {
const snap = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(id));
if (snap?.post) {
const content = snap.post.content ?? '';
const rendered = content.trim()
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
: '';
return {
headings: extractHeadingsFromHtml(rendered),
scrollRoot: null,
title: '文章目录',
author: snap.post.user ?? null,
publishedAt: snap.post.created_at,
viewCount: snap.post.view_count,
authorProfile: snap.authorProfile ?? null,
authorStats: snap.authorStats ?? null,
};
}
}
}
// 自定义单页:从 sitepage 快照派生目录(无作者卡)
const pageM = loc.pathname.match(/^\/page\/([^/]+?)(?:\.html?)?$/);
if (pageM) {
const slug = pageM[1];
const page = getSessionSnapshot<SitePage>(`sitepage:${slug}`);
if (page) {
const content = page.content ?? '';
const rendered = content.trim()
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
: '';
return {
headings: extractHeadingsFromHtml(rendered),
scrollRoot: null,
title: '文章目录',
};
}
}
return null;
});
const [asideOpen, setAsideOpen] = useState(false); const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchPanelOpen, setSearchPanelOpen] = useState(false); const [searchPanelOpen, setSearchPanelOpen] = useState(false);
@@ -92,9 +132,9 @@ export default function MainLayout() {
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0); const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false); const asideEverLoaded = useRef(false);
/** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */ /** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured()); const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating());
const coldBootDone = useRef(isCompose || wasColdBootEnsured()); const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating());
const bootGen = useRef(0); const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => { const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
@@ -214,7 +254,11 @@ export default function MainLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshBoards(); // SSR +data 已 seed boards/stats(或 SPA 冷启动已预热)时跳过首拉,避免重复请求;
// 后台操作 / 手动刷新经 boards-refresh 事件强制重拉
if (getCachedBoards().length === 0 || getCachedStats() === null) {
void refreshBoards();
}
const onRefresh = () => refreshBoards(); const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh); window.addEventListener('boards-refresh', onRefresh);
return () => window.removeEventListener('boards-refresh', onRefresh); return () => window.removeEventListener('boards-refresh', onRefresh);
@@ -334,8 +378,15 @@ export default function MainLayout() {
void transitionTo(nav, path); void transitionTo(nav, path);
}, [nav, notifyUnread, dmUnread]); }, [nav, notifyUnread, dmUnread]);
// 仅首屏跳过一次:SSR/冷启动种子已带未读数(state 从 getBootUnread 初始化),不重复请求;
// 之后 user 变化(登录/退出)、事件刷新与 60s 轮询照常
const unreadImmediateSkipped = useRef(false);
useEffect(() => { useEffect(() => {
refreshUnreadMessages(); if (!unreadImmediateSkipped.current && peekAuthSeed().hasSeed) {
unreadImmediateSkipped.current = true;
} else {
refreshUnreadMessages();
}
const onRefresh = () => refreshUnreadMessages(); const onRefresh = () => refreshUnreadMessages();
window.addEventListener('messages-unread-refresh', onRefresh); window.addEventListener('messages-unread-refresh', onRefresh);
const timer = window.setInterval(refreshUnreadMessages, 60_000); const timer = window.setInterval(refreshUnreadMessages, 60_000);
@@ -386,6 +437,13 @@ export default function MainLayout() {
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签) // 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => { useEffect(() => {
if (isCompose || !showTagCloud) return; if (isCompose || !showTagCloud) return;
// SSR +data / 冷启动预热已 seed 侧栏数据(含空数组)时首屏直接复用,不重复请求;
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
if (layoutRefreshTick === 0 && hasCachedAside()) {
setTags(getCachedTags());
setTagsLoading(false);
return;
}
let cancelled = false; let cancelled = false;
// 有 session 缓存则静默刷新(软刷新不卸空白) // 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true); if (getCachedTags().length === 0) setTagsLoading(true);
@@ -406,6 +464,14 @@ export default function MainLayout() {
const needRecentComments = needAsideData && showRecentComments; const needRecentComments = needAsideData && showRecentComments;
useEffect(() => { useEffect(() => {
if (!needRecentComments) return; if (!needRecentComments) return;
// SSR +data / 冷启动预热已 seed:首屏同步缓存进 state,不重复请求;
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
if (layoutRefreshTick === 0 && hasCachedAside()) {
setRecentComments(getCachedRecentComments());
asideEverLoaded.current = true;
setAsideLoading(false);
return;
}
let cancelled = false; let cancelled = false;
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动 // 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
if (!asideEverLoaded.current && !hasCachedAside()) { if (!asideEverLoaded.current && !hasCachedAside()) {
@@ -432,6 +498,12 @@ export default function MainLayout() {
const needRecentUsers = needAsideData && showRecentUsers; const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => { useEffect(() => {
if (!needRecentUsers) return; if (!needRecentUsers) return;
if (layoutRefreshTick === 0 && hasCachedAside()) {
setRecentUsers(getCachedRecentUsers());
asideEverLoaded.current = true;
setAsideLoading(false);
return;
}
let cancelled = false; let cancelled = false;
if (!asideEverLoaded.current && !hasCachedAside()) { if (!asideEverLoaded.current && !hasCachedAside()) {
setAsideLoading(true); setAsideLoading(true);
@@ -502,14 +574,7 @@ export default function MainLayout() {
const isArticleAside = isPostDetail || isSitePage; const isArticleAside = isPostDetail || isSitePage;
// 帖子有作者 →「作者与目录」;单页仅目录 // 帖子有作者 →「作者与目录」;单页仅目录
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录'; const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
const setPostOutlineSafe = useCallback((outline: { const setPostOutlineSafe = useCallback((outline: PostOutlineState) => {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => {
setPostOutline(outline); setPostOutline(outline);
}, []); }, []);
const layoutCtx = useMemo<LayoutCtx>(() => ({ const layoutCtx = useMemo<LayoutCtx>(() => ({
@@ -541,7 +606,8 @@ export default function MainLayout() {
}; };
return ( return (
<div className={cn('app-shell', forceDesktop && 'ssr-home')}> // Feed / 帖子详情用文档滚动:F5 / 前进后退的滚动位置由浏览器原生历史恢复
<div className={cn('app-shell', !isCompose && (isFeedHome || isPostDetail) && 'app-shell--doc-scroll')}>
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header className="app-header">
<div className="header-inner"> <div className="header-inner">
@@ -820,11 +886,10 @@ export default function MainLayout() {
})} })}
</div> </div>
)} )}
{shellReady ? ( {/* 不在此处包 Suspense:React 19 Fizz 会把 Suspense 边界内大体积/含 <img>
<Suspense fallback={null}> 的同步内容 outlining 为 <div hidden> 分块(禁 JS 爬虫不可见)。
<Outlet context={layoutCtx} /> 懒加载路由的 Suspense 已在 AppRoutes 各路由 element 上就近声明。 */}
</Suspense> {shellReady ? <Outlet context={layoutCtx} /> : null}
) : null}
</main> </main>
{!isCompose && ( {!isCompose && (
@@ -847,6 +912,8 @@ export default function MainLayout() {
headings: postOutline?.headings ?? [], headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null, scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title, outlineTitle: postOutline?.title,
authorProfile: postOutline?.authorProfile ?? null,
authorStats: postOutline?.authorStats ?? null,
} : null} } : null}
/> />
)} )}
@@ -966,6 +1033,8 @@ export default function MainLayout() {
headings: postOutline?.headings ?? [], headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null, scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title, outlineTitle: postOutline?.title,
authorProfile: postOutline?.authorProfile ?? null,
authorStats: postOutline?.authorStats ?? null,
} : null} } : null}
/> />
</div> </div>
@@ -978,6 +1047,19 @@ export default function MainLayout() {
); );
} }
/** 详情页/单页上报到右栏的文章目录与作者信息 */
export type PostOutlineState = {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
/** SSR/预热快照携带的作者主页资料与统计(作者卡首帧直接渲染,缺省由卡片自行拉取) */
authorProfile?: UserPublic | null;
authorStats?: UserActivityStats | null;
} | null;
export type LayoutCtx = { export type LayoutCtx = {
boardId: number; boardId: number;
keyword: string; keyword: string;
@@ -988,12 +1070,5 @@ export type LayoutCtx = {
refreshBoards: () => void; refreshBoards: () => void;
isMobile: boolean; isMobile: boolean;
/** 详情页上报作者与目录,供右侧栏展示 */ /** 详情页上报作者与目录,供右侧栏展示 */
setPostOutline: (outline: { setPostOutline: (outline: PostOutlineState) => void;
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => void;
}; };

View File

@@ -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();

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm, type Resolver } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Plus, FolderKanban } from 'lucide-react'; import { Plus, FolderKanban } from 'lucide-react';
@@ -52,8 +52,9 @@ export default function BoardsManagePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [reordering, setReordering] = useState(false); const [reordering, setReordering] = useState(false);
// z.coerce 的 input 类型为 unknown,这里显式断言对齐 output 类型
const form = useForm<BoardFormValues>({ const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema), resolver: zodResolver(boardSchema) as Resolver<BoardFormValues>,
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 }, defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
}); });

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom'; import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { ArrowLeft, Star } from 'lucide-react'; import { ArrowLeft, Star } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import { import {
useNavigate, useNavigate,
useOutletContext, useOutletContext,
@@ -6,18 +6,23 @@ import {
useLocation, useLocation,
useParams, useParams,
useNavigationType, useNavigationType,
} from 'react-router-dom'; } from 'react-router';
import { Inbox, SearchX } from 'lucide-react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import { api } from '../api/client'; import { api } from '../api/client';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout'; import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList'; import PostListItem from '../components/PostListItem';
import StaticFeedList from '../components/StaticFeedList'; import FeedPagination from '../components/FeedPagination';
import { InFlowSiteFooter } from '../components/SiteFooter';
import FeedHeader from '../components/FeedHeader'; import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters'; import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar'; import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch'; import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
import { import {
navigateFeed, navigateFeed,
FEED_RESET_EVENT, FEED_RESET_EVENT,
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs'; import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost'; import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition'; import { startTransition, doneTransition } from '../utils/spaTransition';
import { isHomeHydrating } from '../utils/homeHydrate';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding'; import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink'; import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -49,7 +53,6 @@ type FeedHydrate = {
posts: PostItem[]; posts: PostItem[];
postTotal: number; postTotal: number;
page: number; page: number;
scrollTop: number;
loading: boolean; loading: boolean;
}; };
@@ -69,11 +72,10 @@ function readHydrate(
posts: cached.posts, posts: cached.posts,
postTotal: cached.postTotal, postTotal: cached.postTotal,
page: cached.page, page: cached.page,
scrollTop: cached.scrollTop,
loading: false, loading: false,
}; };
} }
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true }; return { posts: [], postTotal: 0, page: 1, loading: true };
} }
export default function HomePage() { export default function HomePage() {
@@ -83,6 +85,7 @@ export default function HomePage() {
const { id: boardRouteId } = useParams(); const { id: boardRouteId } = useParams();
const [params] = useSearchParams(); const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>(); const ctx = useOutletContext<LayoutCtx>();
const { user } = useAuth();
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
const { limits, loading: limitsLoading } = useForumLimits(); const { limits, loading: limitsLoading } = useForumLimits();
const postSearch = usePostSearch(limits); const postSearch = usePostSearch(limits);
@@ -187,25 +190,14 @@ export default function HomePage() {
titleOnly, titleOnly,
}); });
const [listPending, setListPending] = useState(false); const [listPending, setListPending] = useState(false);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>( /** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0); const [listResetKey, setListResetKey] = useState(0);
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
useEffect(() => {
if (useVirtualList) return;
reactStartTransition(() => setUseVirtualList(true));
}, [useVirtualList]);
const scrollTopRef = useRef(initial.scrollTop);
const fetchSeqRef = useRef(0); const fetchSeqRef = useRef(0);
const pageRef = useRef(initial.page); const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey); const cacheKeyRef = useRef(cacheKey);
const viewKeyRef = useRef(view.cacheKey); const viewKeyRef = useRef(view.cacheKey);
const postsRef = useRef(posts); const postsRef = useRef(posts);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */ /** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
const hydratedKeyRef = useRef<string | null>(null); const hydratedKeyRef = useRef<string | null>(null);
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */ /** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
@@ -223,8 +215,6 @@ export default function HomePage() {
setPostTotal(next.postTotal); setPostTotal(next.postTotal);
setPage(next.page); setPage(next.page);
pageRef.current = next.page; pageRef.current = next.page;
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading); setLoading(next.loading);
setListPending(false); setListPending(false);
setView(meta); setView(meta);
@@ -254,10 +244,10 @@ export default function HomePage() {
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize)); const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0; const showPagination = totalPages > 1 && posts.length > 0;
const hasMore = page < totalPages; const hasMore = page < totalPages;
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const resetFeedView = useCallback(() => { const resetFeedView = useCallback(() => {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1); setListResetKey(k => k + 1);
}, []); }, []);
@@ -266,12 +256,12 @@ export default function HomePage() {
resetFeedView(); resetFeedView();
}, [resetFeedView]); }, [resetFeedView]);
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */ /** 把当前列表写入 Zustand */
const persistFeed = useCallback(( const persistFeed = useCallback((
nextPosts: PostItem[], nextPosts: PostItem[],
nextTotal: number, nextTotal: number,
nextPage: number, nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string }, opts?: { touchFetchTime?: boolean; key?: string },
) => { ) => {
const key = opts?.key ?? cacheKeyRef.current; const key = opts?.key ?? cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key); const prev = getHomeStoreState().getFeed(key);
@@ -279,7 +269,6 @@ export default function HomePage() {
posts: nextPosts, posts: nextPosts,
postTotal: nextTotal, postTotal: nextTotal,
page: nextPage, page: nextPage,
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
lastFetchTime: opts?.touchFetchTime === false lastFetchTime: opts?.touchFetchTime === false
? (prev?.lastFetchTime ?? Date.now()) ? (prev?.lastFetchTime ?? Date.now())
: Date.now(), : Date.now(),
@@ -309,10 +298,7 @@ export default function HomePage() {
const batch = Array.isArray(data.posts) ? data.posts : []; const batch = Array.isArray(data.posts) ? data.posts : [];
const total = data.total ?? 0; const total = data.total ?? 0;
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current); const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
if (jumpTop) { if (jumpTop) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1); setListResetKey(k => k + 1);
} }
setPosts(batch); setPosts(batch);
@@ -320,7 +306,7 @@ export default function HomePage() {
setPage(p); setPage(p);
pageRef.current = p; pageRef.current = p;
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly }); setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey }); persistFeed(batch, total, p, { touchFetchTime: true, key: requestKey });
} catch (e: unknown) { } catch (e: unknown) {
if (seq !== fetchSeqRef.current) return; if (seq !== fetchSeqRef.current) return;
if (!silent) { if (!silent) {
@@ -352,8 +338,6 @@ export default function HomePage() {
}, [fetchPage, postTotal, pageSize]); }, [fetchPage, postTotal, pageSize]);
const handleSelectPost = useCallback((id: number) => { const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab); openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]); }, [nav, limits.open_posts_in_new_tab]);
@@ -399,7 +383,6 @@ export default function HomePage() {
posts: cached.posts, posts: cached.posts,
postTotal: cached.postTotal, postTotal: cached.postTotal,
page: cached.page, page: cached.page,
scrollTop: cached.scrollTop,
loading: false, loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly }); }, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else { } else {
@@ -415,10 +398,6 @@ export default function HomePage() {
} }
hydratedKeyRef.current = cacheKey; hydratedKeyRef.current = cacheKey;
if (postsRef.current.length === 0) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
}
loadFirst(); loadFirst();
}, [ }, [
limitsLoading, limitsLoading,
@@ -466,7 +445,6 @@ export default function HomePage() {
posts: warm.posts, posts: warm.posts,
postTotal: warm.postTotal, postTotal: warm.postTotal,
page: warm.page, page: warm.page,
scrollTop: 0,
loading: false, loading: false,
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly }); }, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
setListResetKey((k) => k + 1); setListResetKey((k) => k + 1);
@@ -495,20 +473,21 @@ export default function HomePage() {
sort, boardId, keyword, tag, author, titleOnly, sort, boardId, keyword, tag, author, titleOnly,
]); ]);
// 卸载时取消未执行的 scroll rAF // 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
useEffect(() => () => { // - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current); // - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
}, []); // 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
useLayoutEffect(() => {
if (listResetKey <= 0) return;
window.scrollTo(0, 0);
}, [listResetKey]);
const handleScrollTopChange = useCallback((top: number) => { // PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
scrollTopRef.current = top; // POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者 useEffect(() => {
if (scrollRafRef.current) return; if (navType === 'POP') return;
scrollRafRef.current = requestAnimationFrame(() => { window.scrollTo(0, 0);
scrollRafRef.current = 0; }, [cacheKey, navType]);
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => { const handleSortChange = (next: FeedSort) => {
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }); const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
@@ -527,6 +506,32 @@ export default function HomePage() {
const searchFilters = parseSearchFromUrl(location.pathname, params); const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(view.keyword || view.author); const isSearchActive = !!(view.keyword || view.author);
// 空态(加载中且无列表时已在上方 return null,此处只处理真空态)
const viewBoardName = ctx?.boards?.find(b => b.id === view.boardId)?.name || '';
const noBoards = !ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0;
const isSearchEmpty = posts.length === 0 && !loading
&& (isSearchActive || !!view.tag || !!(view.keyword || '').trim());
const composeTarget = view.boardId > 0 ? `/compose?board=${view.boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
const searchSummaryParts: string[] = [];
const kw = (view.keyword || view.tag || '').trim();
if (kw) searchSummaryParts.push(`关键词「${kw}」`);
if (view.author?.trim()) searchSummaryParts.push(`作者「${view.author.trim()}」`);
if (view.titleOnly) searchSummaryParts.push('仅标题');
if (searchFilters.scopeBoardId) {
const bn = ctx?.boards?.find(b => b.id === searchFilters.scopeBoardId)?.name || '';
if (bn) searchSummaryParts.push(`板块「${bn}」`);
}
const emptyHint = noBoards
? (isAdmin
? '网站还没有任何板块。你可以创建第一个板块,给帖子们一个归属。'
: '网站刚刚创建,管理员还没有添加板块。稍后再来看看吧。')
: isSearchEmpty
? `${searchSummaryParts.join(' · ')}${searchSummaryParts.length ? '。' : ''}换个关键词试试,或清除当前筛选条件。`
: (view.boardId > 0
? `板块「${viewBoardName}」还没有帖子。看看其他板块,或发表这个板块的第一帖。`
: '成为第一个发帖的人,开始讨论吧。');
if (isInvalidBoardRoute || isMissingBoard) { if (isInvalidBoardRoute || isMissingBoard) {
return ( return (
<NotFoundPage <NotFoundPage
@@ -546,25 +551,13 @@ export default function HomePage() {
<div className="feed-panel"> <div className="feed-panel">
<div className="feed-top"> <div className="feed-top">
<div className="feed-top__bar"> <div className="feed-top__bar">
{!useVirtualList ? ( <FeedHeader
(view.keyword || view.tag || view.author) ? ( keyword={view.keyword}
<h1 className="feed-header-title"> tag={view.tag}
{view.tag author={view.author}
? `#${view.tag}` postTotal={postTotal}
: view.keyword titleAs={isSiteHome ? 'h2' : 'h1'}
? `搜索:${view.keyword}` />
: `作者:${view.author}`}
</h1>
) : null
) : (
<FeedHeader
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
)}
{showSortBar && ( {showSortBar && (
<FeedSortBar <FeedSortBar
value={view.sort} value={view.sort}
@@ -583,36 +576,79 @@ export default function HomePage() {
/> />
)} )}
</div> </div>
{!useVirtualList ? ( {/*
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} /> 列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
) : ( 不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
<VirtualPostList 也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
posts={posts} */}
sort={view.sort} <div className="post-list-scroll post-list-scroll--ssr">
loading={listPending ? false : (loading || limitsLoading)} {posts.length === 0 ? (
hasMore={hasMore} <div className="empty-feed" role="status">
showPagination={showPagination} {isSearchEmpty
page={page} ? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
totalPages={totalPages} : <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
postTotal={postTotal} <p>{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
onPageChange={goToPage} <p className="empty-feed-hint">{emptyHint}</p>
onSelect={handleSelectPost} <div className="empty-feed-actions">
restoreScrollTop={restoreScrollTop} {noBoards ? (
resetScrollKey={listResetKey} isAdmin ? (
onScrollTopChange={handleScrollTopChange} <Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
onScrollRestored={() => setRestoreScrollTop(null)} ) : (
keyword={view.keyword || view.tag || view.author} <Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
isSearchMode={!!(view.keyword || view.author)} )
searchKeyword={view.keyword} ) : isSearchEmpty ? (
searchAuthor={view.author} <>
searchTitleOnly={view.titleOnly} <Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
searchScopeBoardId={searchFilters.scopeBoardId} <Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
onClearSearch={postSearch.clearSearch} <Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
boardId={view.boardId} </>
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''} ) : view.boardId > 0 ? (
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0} <>
/> <Button variant="ghost" onClick={() => nav('/')}>看看其他板块</Button>
)} {user ? (
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
) : (
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
)}
</>
) : user ? (
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
) : (
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
)}
</div>
</div>
) : (
<>
<div className="content-surface content-surface--ssr">
{posts.map((post) => (
<PostListItem
key={post.id}
post={post}
sort={view.sort}
boardId={view.boardId}
onSelect={handleSelectPost}
/>
))}
</div>
{showPagination && (
<div className="feed-list-footer feed-list-footer--pagination">
<FeedPagination
page={page}
totalPages={totalPages}
postTotal={postTotal}
loading={loading}
onPageChange={goToPage}
/>
</div>
)}
{showEnd && (
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
)}
<InFlowSiteFooter />
</>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router';
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react'; import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link, useSearchParams } from 'react-router-dom'; import { useNavigate, Link, useSearchParams } from 'react-router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router';
import { import {
ArrowLeft, ArrowLeft,
AtSign, AtSign,

View File

@@ -1,4 +1,4 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { FileQuestion, Home } from 'lucide-react'; import { FileQuestion, Home } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePageSEO } from '../hooks/usePageSEO'; import { usePageSEO } from '../hooks/usePageSEO';

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; 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 { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon'; import FeaturedIcon from '@/components/FeaturedIcon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -33,7 +33,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types'; import type { PostItem, Comment, PollView, PostLotteryView, ReportReason } from '../api/types';
import { REPORT_REASON_OPTIONS } from '../utils/report'; import { REPORT_REASON_OPTIONS } from '../utils/report';
import CommentThreadList from '../components/CommentThreadList'; import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox'; import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
@@ -48,15 +48,15 @@ import ArticleOutline from '../components/ArticleOutline';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding } from '../hooks/useSiteBranding'; import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { formatDateTime, isTimeDiffSignificant } from '../utils/content'; import { formatDateTime } from '../utils/content';
import { collectCommentSubtreeIds } from '../utils/comment'; import { collectCommentSubtreeIds } from '../utils/comment';
import { loadMyCommentIds } from '../utils/guest'; import { loadMyCommentIds } from '../utils/guest';
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText'; import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
import { buildPostDetailSnapshot, postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink'; import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import type { LayoutCtx } from '../layouts/MainLayout'; import type { LayoutCtx } from '../layouts/MainLayout';
@@ -65,27 +65,6 @@ import { renderPostContentHtml } from '../utils/postContent';
import { InFlowSiteFooter } from '../components/SiteFooter'; import { InFlowSiteFooter } from '../components/SiteFooter';
import NotFoundPage from './NotFoundPage'; import NotFoundPage from './NotFoundPage';
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function postDetailCacheKey(id: number) {
return `post:${id}`;
}
/** 格式化剩余可编辑时间 */ /** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string { function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return ''; if (windowHours <= 0) return '';
@@ -145,24 +124,17 @@ export default function PostDetailPage() {
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true); const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? ''); const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0); const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */ const [authorProfile, setAuthorProfile] = useState<PostDetailSnapshot['authorProfile']>(initialSnap?.authorProfile ?? null);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => { const [authorStats, setAuthorStats] = useState<PostDetailSnapshot['authorStats']>(initialSnap?.authorStats ?? 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 commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = 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 postRef = useRef<PostItem | null>(null);
const scrollTopRef = useRef(
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
);
postRef.current = post; postRef.current = post;
useGlobalWheelScroll(pageRef, !loading && !!post); // 滚动位置完全交给浏览器原生历史恢复(详情页为文档滚动,见 .app-shell--doc-scroll),
// 会话快照只缓存数据让 POP 后退时内容即时渲染、文档即刻够高,原生恢复才有正确落点。
// SPA 内跳转时纠正非规范伪静态路径 // SPA 内跳转时纠正非规范伪静态路径
useEffect(() => { useEffect(() => {
@@ -212,14 +184,16 @@ export default function PostDetailPage() {
} }
setPostOutline({ setPostOutline({
headings, headings,
scrollRoot: pageRef.current, scrollRoot: null,
title: '文章目录', title: '文章目录',
author: post.user ?? null, author: post.user ?? null,
publishedAt: post.created_at, publishedAt: post.created_at,
viewCount: post.view_count, viewCount: post.view_count,
authorProfile,
authorStats,
}); });
return () => setPostOutline(null); return () => setPostOutline(null);
}, [headings, loading, post, setPostOutline]); }, [headings, loading, post, authorProfile, authorStats, setPostOutline]);
const loadSeq = useRef(0); const loadSeq = useRef(0);
const detailPath = postPath(postId, limits); const detailPath = postPath(postId, limits);
@@ -237,16 +211,12 @@ export default function PostDetailPage() {
setBountyCanRefund(snap.bountyCanRefund); setBountyCanRefund(snap.bountyCanRefund);
setBountyRefundBlockReason(snap.bountyRefundBlockReason); setBountyRefundBlockReason(snap.bountyRefundBlockReason);
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount); setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
setAuthorProfile(snap.authorProfile ?? null);
setAuthorStats(snap.authorStats ?? null);
setComments(snap.comments); setComments(snap.comments);
if (opts?.restoreScroll) { // POP(后退/前进)不干预,交给浏览器原生恢复;
scrollTopRef.current = snap.scrollTop; // PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
setRestoreScrollTop(snap.scrollTop); if (!opts?.restoreScroll) window.scrollTo(0, 0);
} else {
scrollTopRef.current = 0;
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
const el = pageRef.current;
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
}
}, []); }, []);
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {}); const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
@@ -290,29 +260,13 @@ export default function PostDetailPage() {
]); ]);
if (seq !== loadSeq.current) return; if (seq !== loadSeq.current) return;
const commentsList = Array.isArray(comm.comments) ? comm.comments : []; const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
const snap: PostDetailSnapshot = { // 作者主页资料一并拉取(右栏作者卡首帧完整;失败降级为卡片自行拉取)
post: detail.post, const authorResp = detail.post.user_id
comments: commentsList, ? await api.userProfile(detail.post.user_id).catch(() => null)
poll: detail.poll ?? null, : null;
lottery: detail.lottery ?? null, const snap = buildPostDetailSnapshot(detail, commentsList, authorResp);
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
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); setSessionSnapshot(postDetailCacheKey(postId), snap);
applySnapshot(snap, { restoreScroll: false }); applySnapshot(snap, { restoreScroll: false });
if (mode === 'force') {
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
}
void refresh(); void refresh();
} catch { } catch {
if (seq !== loadSeq.current) return; if (seq !== loadSeq.current) return;
@@ -342,23 +296,6 @@ export default function PostDetailPage() {
}; };
}, [postId, applySnapshot]); }, [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(() => { useEffect(() => {
if (!post || post.id !== postId || loading) return; if (!post || post.id !== postId || loading) return;
setSessionSnapshot(postDetailCacheKey(post.id), { setSessionSnapshot(postDetailCacheKey(post.id), {
@@ -375,22 +312,16 @@ export default function PostDetailPage() {
bountyCanRefund, bountyCanRefund,
bountyRefundBlockReason, bountyRefundBlockReason,
bountyEligibleReplyCount, bountyEligibleReplyCount,
scrollTop: scrollTopRef.current, authorProfile,
authorStats,
scrollTop: 0,
}); });
}, [ }, [
post, comments, poll, lottery, liked, favorited, canEdit, isEdited, post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason, editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
bountyEligibleReplyCount, postId, loading, bountyEligibleReplyCount, authorProfile, authorStats, 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 reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds(); const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds); const comm = await api.comments(postId, myIds);
@@ -460,102 +391,55 @@ export default function PostDetailPage() {
return () => root.removeEventListener('focusout', onFocusOut); return () => root.removeEventListener('focusout', onFocusOut);
}, [isMobile, composerOpen]); }, [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 jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`); const el = document.getElementById(`floor-${floor}`);
if (!el) return false; if (!el) return false;
const root = pageRef.current; scrollAnchorIntoView(el);
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);
setHighlightFloor(floor); setHighlightFloor(floor);
clearTimeout(highlightTimer.current); clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000); highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
return true; return true;
}, []); }, [scrollAnchorIntoView]);
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */ /** 正文标题锚点:等正文进 DOM 后由调用方重试 */
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => { const jumpToHeadingHash = useCallback((hash: string) => {
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim(); const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
if (!id || /^floor-\d+$/.test(id)) return false; if (!id || /^floor-\d+$/.test(id)) return false;
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return false; if (!el) return false;
const root = pageRef.current; scrollAnchorIntoView(el);
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);
}
return true; return true;
}, []); }, [scrollAnchorIntoView]);
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效 // 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
useEffect(() => { useEffect(() => {
@@ -597,7 +481,7 @@ export default function PostDetailPage() {
const tryJump = () => { const tryJump = () => {
if (cancelled) return; if (cancelled) return;
if (jumpToHeadingHash(hash, false)) return; if (jumpToHeadingHash(hash)) return;
attempts += 1; attempts += 1;
if (attempts < 30) { if (attempts < 30) {
timer = window.setTimeout(tryJump, 50); timer = window.setTimeout(tryJump, 50);
@@ -806,7 +690,8 @@ export default function PostDetailPage() {
const handlePin = async () => { const handlePin = async () => {
if (!post) return; if (!post) return;
try { try {
const r = await api.adminPinPost(postId, !post.pinned); const next = post.pinned > 0 ? 0 : 1;
const r = await api.adminPinPost(postId, next);
setPost(p => p ? { ...p, pinned: r.pinned } : p); setPost(p => p ? { ...p, pinned: r.pinned } : p);
clearAllFeedCache(); clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh')); window.dispatchEvent(new Event('posts-refresh'));
@@ -819,7 +704,8 @@ export default function PostDetailPage() {
const handleBoardPin = async () => { const handleBoardPin = async () => {
if (!post) return; if (!post) return;
try { try {
const r = await api.adminBoardPinPost(postId, !post.board_pinned); const next = (post.board_pinned ?? 0) > 0 ? 0 : 1;
const r = await api.adminBoardPinPost(postId, next);
setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p); setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p);
clearAllFeedCache(); clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh')); window.dispatchEvent(new Event('posts-refresh'));
@@ -944,7 +830,7 @@ export default function PostDetailPage() {
}; };
return ( 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-header">
<div className="post-detail-nav"> <div className="post-detail-nav">
<div className="post-detail-nav-left"> <div className="post-detail-nav-left">
@@ -995,10 +881,10 @@ export default function PostDetailPage() {
<div className="post-detail-head"> <div className="post-detail-head">
<h1 className="post-detail-title"> <h1 className="post-detail-title">
{post.pinned && ( {post.pinned > 0 && (
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span> <span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span>
)} )}
{post.board_pinned && ( {(post.board_pinned ?? 0) > 0 && (
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span> <span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span>
)} )}
{post.featured && <FeaturedIcon className="mr-2" size={18} />} {post.featured && <FeaturedIcon className="mr-2" size={18} />}
@@ -1081,7 +967,6 @@ export default function PostDetailPage() {
<summary>文章目录({headings.length})</summary> <summary>文章目录({headings.length})</summary>
<ArticleOutline <ArticleOutline
headings={headings} headings={headings}
scrollRoot={pageRef.current}
title="目录" title="目录"
/> />
</details> </details>

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'; 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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -91,7 +91,7 @@ export default function ProfilePage() {
const [idCopied, setIdCopied] = useState(false); const [idCopied, setIdCopied] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0); const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(); const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const sigSectionRef = useRef<HTMLDivElement>(null); const sigSectionRef = useRef<HTMLDivElement>(null);
const pendingFocusSig = useRef(false); const pendingFocusSig = useRef(false);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { ArrowLeft, FolderGit2, Search } from 'lucide-react'; import { ArrowLeft, FolderGit2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; 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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { ArrowLeft, ExternalLink, Globe2 } from 'lucide-react'; import { ArrowLeft, ExternalLink, Globe2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useOutletContext, useParams } from 'react-router-dom'; import { useOutletContext, useParams } from 'react-router';
import NotFoundPage from './NotFoundPage'; import NotFoundPage from './NotFoundPage';
import { api } from '../api/client'; import { api } from '../api/client';
import type { SitePage } from '../api/types'; import type { SitePage } from '../api/types';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom'; import { useNavigate, useParams, useLocation } from 'react-router';
import { import {
ArrowLeft, ArrowLeft,
FileText, FileText,

View File

@@ -170,9 +170,9 @@ export default function AdminBadgesPage() {
setReordering(true); setReordering(true);
try { try {
const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered); const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered);
const after = await persistSortOrderChanges(before, merged, badge => const after = await persistSortOrderChanges(before, merged, async badge => {
api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order }), await api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order });
); });
setRows(after); setRows(after);
notify.success('徽章排序已更新'); notify.success('徽章排序已更新');
} catch (e: unknown) { } catch (e: unknown) {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; 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 { Trash2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -53,7 +53,7 @@ export default function AdminCommentsPage() {
const [revComment, setRevComment] = useState<Comment | null>(null); const [revComment, setRevComment] = useState<Comment | null>(null);
const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null); const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null);
const focusTriedRef = useRef(false); 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) => { const loadList = (p = page, st: Tab = tab) => {
setLoading(true); setLoading(true);

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Globe2, ExternalLink, Star } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react'; import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -246,9 +246,9 @@ export default function AdminDashboardPage() {
</td> </td>
<td className="space-x-1"> <td className="space-x-1">
{p.featured ? <Badge variant="orange">推荐</Badge> : null} {p.featured ? <Badge variant="orange">推荐</Badge> : null}
{p.pinned ? <Badge variant="green">全局置顶</Badge> : null} {p.pinned > 0 ? <Badge variant="green">全局置顶</Badge> : null}
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null} {(p.board_pinned ?? 0) > 0 ? <Badge variant="green">板块置顶</Badge> : null}
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null} {!p.featured && p.pinned <= 0 && (p.board_pinned ?? 0) <= 0 ? '—' : null}
</td> </td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td> <td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr> </tr>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router';
import { import {
Link2, Plus, Trash2, Check, X, ExternalLink, Eye, RotateCcw, Link2, Plus, Trash2, Check, X, ExternalLink, Eye, RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
@@ -173,24 +173,27 @@ export default function AdminLinksPage() {
aside_show_friend_links: s.limits?.aside_show_friend_links ?? true, aside_show_friend_links: s.limits?.aside_show_friend_links ?? true,
}), }),
); );
const base: ForumLimits = { // 默认值在前、server limits 覆盖在后(JSON 缺 key 不覆盖)
open_posts_in_new_tab: true, const base: ForumLimits = Object.assign(
open_content_links_in_new_tab: true, {
aside_show_tag_cloud: false, open_posts_in_new_tab: true,
aside_show_recent_comments: false, open_content_links_in_new_tab: true,
aside_show_friend_links: true, aside_show_tag_cloud: false,
aside_show_showcase: false, aside_show_recent_comments: false,
aside_widgets: loadedAsideWidgets, aside_show_friend_links: true,
nav_show_friend_links: true, aside_show_showcase: false,
footer_show_friend_links: true, aside_widgets: loadedAsideWidgets,
nav_show_showcase: false, nav_show_friend_links: true,
footer_show_showcase: false, footer_show_friend_links: true,
feed_list_style: 'title', nav_show_showcase: false,
feed_sort_tabs: DEFAULT_FEED_SORT_TABS, footer_show_showcase: false,
permalink_enabled: false, feed_list_style: 'title',
permalink_ext: 'html', feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
...s.limits, permalink_enabled: false,
}; permalink_ext: 'html',
} as ForumLimits,
s.limits,
);
setForumLimits(mergeForumLimitsWithAsideWidgets(base, loadedAsideWidgets)); setForumLimits(mergeForumLimitsWithAsideWidgets(base, loadedAsideWidgets));
}) })
.catch(() => { /* 入口开关稍后仍可按默认展示 */ }); .catch(() => { /* 入口开关稍后仍可按默认展示 */ });

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { Activity, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react'; import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -76,8 +76,8 @@ export default function AdminPagesPage() {
const before = [...rows]; const before = [...rows];
setReordering(true); setReordering(true);
try { try {
const after = await persistSortOrderChanges(before, reordered, page => const after = await persistSortOrderChanges(before, reordered, async page => {
api.adminUpdatePage(page.id, { await api.adminUpdatePage(page.id, {
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
content: page.content, content: page.content,
@@ -85,8 +85,8 @@ export default function AdminPagesPage() {
sort_order: page.sort_order, sort_order: page.sort_order,
show_in_footer: page.show_in_footer, show_in_footer: page.show_in_footer,
show_in_nav: page.show_in_nav, show_in_nav: page.show_in_nav,
}), });
); });
setRows(after); setRows(after);
invalidateSitePagesCache(); invalidateSitePagesCache();
notify.success('单页排序已更新'); notify.success('单页排序已更新');

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; 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 { Search, Lock, LockOpen, MessageSquareOff, Trash2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -46,7 +46,7 @@ export default function AdminPostsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null); const [highlightId, setHighlightId] = useState<number | null>(focusId > 0 ? focusId : null);
const focusTriedRef = useRef(false); 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') => { const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => {
setLoading(true); setLoading(true);
@@ -141,9 +141,13 @@ export default function AdminPostsPage() {
setSearch(''); setSearch('');
}; };
const togglePin = async (post: PostItem) => { const togglePin = async (post: PostItem, nextValue?: number) => {
try { try {
const r = await api.adminPinPost(post.id, !post.pinned); let value = nextValue;
if (value === undefined) {
value = post.pinned > 0 ? 0 : 1;
}
const r = await api.adminPinPost(post.id, value);
clearAllFeedCache(); clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh')); window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message); notify.success(r.message);
@@ -153,9 +157,13 @@ export default function AdminPostsPage() {
} }
}; };
const toggleBoardPin = async (post: PostItem) => { const toggleBoardPin = async (post: PostItem, nextValue?: number) => {
try { try {
const r = await api.adminBoardPinPost(post.id, !post.board_pinned); let value = nextValue;
if (value === undefined) {
value = (post.board_pinned ?? 0) > 0 ? 0 : 1;
}
const r = await api.adminBoardPinPost(post.id, value);
clearAllFeedCache(); clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh')); window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message); notify.success(r.message);
@@ -417,8 +425,8 @@ export default function AdminPostsPage() {
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td> <td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td> <td>{p.comment_count ?? 0}</td>
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td> <td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td> <td>{p.pinned > 0 ? <Badge variant="green">置顶 {p.pinned}</Badge> : '—'}</td>
<td>{p.board_pinned ? <Badge variant="green">是</Badge> : '—'}</td> <td>{(p.board_pinned ?? 0) > 0 ? <Badge variant="green">置顶 {p.board_pinned}</Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td> <td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td> <td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
<td>{p.like_count}</td> <td>{p.like_count}</td>
@@ -444,11 +452,55 @@ export default function AdminPostsPage() {
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}> <Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
{p.featured ? '取消推荐' : '推荐'} {p.featured ? '取消推荐' : '推荐'}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => togglePin(p)}> <Button size="sm" variant="outline" onClick={() => {
{p.pinned ? '取消全局置顶' : '全局置顶'} const cur = p.pinned;
if (cur > 0) {
// 已置顶:弹出输入框让管理员调整顺序值
const input = window.prompt(`全局置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void togglePin(p, n);
} else {
// 未置顶:输入框让管理员设置初始值
const input = window.prompt('设置全局置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void togglePin(p, n);
}
}}>
{p.pinned > 0 ? `全局置顶(${p.pinned})` : '全局置顶'}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => toggleBoardPin(p)}> <Button size="sm" variant="outline" onClick={() => {
{p.board_pinned ? '取消板块置顶' : '板块置顶'} const cur = p.board_pinned ?? 0;
if (cur > 0) {
const input = window.prompt(`板块置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void toggleBoardPin(p, n);
} else {
const input = window.prompt('设置板块置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void toggleBoardPin(p, n);
}
}}>
{(p.board_pinned ?? 0) > 0 ? `板块置顶(${p.board_pinned})` : '板块置顶'}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}> <Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>} {p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';

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