Compare commits
69 Commits
bb8d415eb7
...
rebuild/gi
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba010f3ea | |||
| 6e7b46a2e9 | |||
| e0683ef262 | |||
| 3bd0b990a1 | |||
| 58b94df50c | |||
| 4a21d362d0 | |||
| 1f777eebb1 | |||
| 7f048bd9b5 | |||
| f0358e0f71 | |||
| 118f967540 | |||
| 4ffcaecba2 | |||
| 9e403a7178 | |||
| 0c5fe8a1b4 | |||
| 02a5ad0c30 | |||
| 5fb3b2a165 | |||
| ae448e1ccc | |||
| e06c54870c | |||
| b5aee606fb | |||
| 303755b47a | |||
| 92b68b9eef | |||
| c8533b6184 | |||
| 4f93397ef3 | |||
| 7f21ffb14e | |||
| f10ba99012 | |||
| c32c81d1ca | |||
| 145c7a3e1f | |||
| 54f5de07a4 | |||
| 5e623f67fb | |||
| 2d09729976 | |||
| 83994e2707 | |||
| 802f86605c | |||
| c4faba81b3 | |||
| 959a2c3a2e | |||
| f76d3e3bf8 | |||
| 2a46c01c85 | |||
| d4b29f5b7c | |||
| 4dc196b055 | |||
| 7bc50bfb80 | |||
| 204e7fdb32 | |||
| 40ec20b3df | |||
| 126e3bae98 | |||
| fde5f628ec | |||
| 3f50316ad0 | |||
| 9fe299a45f | |||
| 1414c71dec | |||
| 4dc3bbb13f | |||
| 8cfe31da61 | |||
| 7965768f3d | |||
| 012cac23de | |||
| d929bf96aa | |||
| cce9fb9ed8 | |||
| 9eefe24a33 | |||
| f559fc4c4b | |||
| 55b1831da2 | |||
| c6df703b96 | |||
| 7cc574f9a1 | |||
| 2208af7070 | |||
| df19752a1e | |||
| c07d1fd2bd | |||
| 4f85a86077 | |||
| f0650a9c39 | |||
| 4f42a2faff | |||
| 536707f812 | |||
| 1550ccb693 | |||
| 4042395e3e | |||
| 383440ed5b | |||
| 94f6a9666b | |||
| 10185178e2 | |||
| 0f83183620 |
@@ -5,7 +5,8 @@ alwaysApply: true
|
||||
|
||||
# 编译与构建脚本
|
||||
|
||||
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||
本分支(`rebuild/gitea-ssr`):Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
|
||||
对照 SPA 仅在 `main` 分支。
|
||||
|
||||
## 运行编译(优先用封装命令,不要猜命令)
|
||||
|
||||
@@ -18,15 +19,16 @@ Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||
|
||||
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||
|
||||
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||
常用 target:`build`(默认)、`dev`、`run`、`web-src`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||
|
||||
## 修改构建脚本时的约定
|
||||
|
||||
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||
4. **构建顺序**:先 `web_src` 内 `npm run build`(产出 `public/assets`),再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||
6. **本分支无 `frontend/`**:勿再添加 SPA 构建步骤;需要对照 UI 时 `git checkout main`。
|
||||
|
||||
## 新增 target 检查清单
|
||||
|
||||
|
||||
37
.cursor/rules/rebuild-gitea-ssr.mdc
Normal file
37
.cursor/rules/rebuild-gitea-ssr.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Gitea 式 SSR 重构总则(rebuild/gitea-ssr 分支)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Gitea 式 SSR 重构
|
||||
|
||||
## 分支
|
||||
|
||||
- 功能开发在 **`rebuild/gitea-ssr`**;**不要**把破坏性 SSR 替换推到 `main`。
|
||||
- `main` 保留 React SPA,用作对照(checkout / worktree)。
|
||||
|
||||
## 渲染
|
||||
|
||||
- 公开页(首页、板块、帖详情、用户页等)必须用 **Go `html/template` 服务端渲染完整 HTML**。
|
||||
- 禁止为已迁移公开页恢复 React SPA 空壳;禁止「用户 SPA + 爬虫专用 HTML」双轨作为长期方案。
|
||||
- JSON `/api` 仅用于交互增强与管理后台,**不得**作为公开页首屏唯一数据来源。
|
||||
|
||||
## 架构参照
|
||||
|
||||
对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分:
|
||||
|
||||
- `routers/web` — HTML 页面路由
|
||||
- `routers/api` — JSON API(原 `handler/`)
|
||||
- `routers/setup.go` — 路由总装
|
||||
- `templates/` — 模板
|
||||
- `web_src/` → `public/assets/` — 渐进增强 CSS/JS
|
||||
- `models/`、`services/` — 数据与业务
|
||||
- `modules/auth`、`modules/webrender`、`modules/seo` — 横切
|
||||
|
||||
本分支**已删除** `frontend/` 与 `embed_static/`;勿再恢复 SPA 生产回落。
|
||||
|
||||
细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。
|
||||
|
||||
## 产品规格
|
||||
|
||||
实现功能前按 [`docs/rebuild-spec/README.md`](docs/rebuild-spec/README.md) 阅读顺序核对;业务规则以 `05-business-rules.md` 为准。
|
||||
20
.cursor/rules/rebuild-spec-source.mdc
Normal file
20
.cursor/rules/rebuild-spec-source.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: 产品规格为唯一业务事实来源
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 规格文档对照
|
||||
|
||||
重构实现时以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准,不另发明业务语义。
|
||||
|
||||
| 需求类型 | 查阅 |
|
||||
|----------|------|
|
||||
| 要不要做某功能 | `02-features.md` |
|
||||
| 表字段 / 枚举 / settings 键 | `03-data-model.md` |
|
||||
| HTTP 路径与 JSON 形状 | `04-api.md` |
|
||||
| 审核 / 积分 / 门控 / 悬赏等 | `05-business-rules.md` |
|
||||
| 路由与交互信息架构 | `06-pages-ux.md` |
|
||||
| 配置与部署 | `07-config-ops.md` |
|
||||
| SSR 目录与分支 | `08-gitea-ssr-architecture.md` |
|
||||
|
||||
规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。
|
||||
14
.cursor/rules/rebuild-templates.mdc
Normal file
14
.cursor/rules/rebuild-templates.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Go HTML 模板约定(SSR)
|
||||
globs: templates/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 模板约定
|
||||
|
||||
- 目录对齐 Gitea:`templates/base/`、`home/`、`post/`、`shared/`、`status/`、`auth/`,根级 `install.tmpl` / `post-install.tmpl`。
|
||||
- 页面入口用固定 `{{define "home"}}` / `{{define "post"}}` 等显式 `{{template "base/head"}}`…;**禁止** `{{template .Name}}`(标准库不支持动态名)。
|
||||
- **默认转义**;用户 HTML 须先消毒 + 门控后再 `{{safeHTML ...}}`。
|
||||
- 静态资源 `/ssr-assets/...`。
|
||||
- 浏览器写操作走 `routers/web` 表单 POST + CSRF(`webctx`),不依赖 JSON `/api`。
|
||||
- 中文文案可写在模板;站点名等从 PageChrome / settings 传入。
|
||||
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Git 与编辑器
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.trae
|
||||
|
||||
# 运行时数据与本地配置
|
||||
data/
|
||||
app.ini
|
||||
tmp-cookie.txt
|
||||
|
||||
# 编译产物与依赖缓存
|
||||
dist/
|
||||
web_src/node_modules/
|
||||
public/assets/
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# 文档与截图(不参与镜像构建)
|
||||
docs/
|
||||
|
||||
# 计划文件
|
||||
*.plan.md
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -4,22 +4,27 @@
|
||||
# 本地配置(保留 app.ini.example)
|
||||
/app.ini
|
||||
|
||||
# 前端依赖与构建缓存
|
||||
# Node / 构建缓存
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/embed_static/static/spa/
|
||||
/web_src/node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go 编译产物
|
||||
# Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
|
||||
/dist/
|
||||
*.exe
|
||||
/jiang13
|
||||
/jiang13-*
|
||||
!/cmd/jiang13/
|
||||
|
||||
# 临时文件
|
||||
tmp-cookie.txt
|
||||
*-err.txt
|
||||
*-out.txt
|
||||
|
||||
# 编辑器 / OS
|
||||
# 编辑器 / AI 草稿 / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.trae/
|
||||
*.swp
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
## 开发环境
|
||||
|
||||
**要求:** Go 1.26+、Node.js 18+
|
||||
**要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源)
|
||||
|
||||
本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`。
|
||||
|
||||
```bat
|
||||
REM Windows:一键启动后端 + 前端热更新(请用 build.bat)
|
||||
REM Windows:请用 build.bat(内部 Bypass ExecutionPolicy)
|
||||
build.bat -Target dev
|
||||
REM 浏览器访问 http://localhost:5173
|
||||
REM 浏览器访问 http://localhost:3000
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -20,15 +22,14 @@ make dev
|
||||
## 提交规范
|
||||
|
||||
- 一个 PR 只做一件事,保持 diff 小而清晰
|
||||
- 前端改动请确认浅色 / 暗色主题下都正常
|
||||
- 前端(`web_src` / 模板)改动请确认浅色 / 暗色主题下都正常
|
||||
- 涉及 UI 变更时,建议在 PR 中附上截图
|
||||
- 功能语义以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准
|
||||
|
||||
## 完整构建
|
||||
|
||||
发布单二进制前需先构建前端并 embed:
|
||||
|
||||
```bat
|
||||
build.bat REM Windows
|
||||
build.bat REM Windows:先 web_src,再 go build → dist/
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -38,15 +39,6 @@ make build # Linux / macOS
|
||||
## 报告问题
|
||||
|
||||
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
|
||||
|
||||
1. 复现步骤
|
||||
2. 期望行为 vs 实际行为
|
||||
3. 环境信息(系统、浏览器、Go/Node 版本)
|
||||
4. 截图或日志(如有)
|
||||
|
||||
演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||
已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。
|
||||
|
||||
## 行为准则
|
||||
|
||||
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||
- 期望行为与实际行为
|
||||
- 复现步骤、浏览器 / OS
|
||||
- 相关模板或 `routers/web` 路径(若已知)
|
||||
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# 姜十三论坛 — 多阶段构建:web_src → Go 单二进制 → Alpine 运行镜像
|
||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||
#
|
||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||
# docker build --build-arg IMAGE_PREFIX= ...
|
||||
|
||||
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||
ARG VERSION=dev
|
||||
|
||||
# ── Stage 1: SSR 渐进资源(web_src → public/assets)────────────────────────
|
||||
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS websrc
|
||||
WORKDIR /src/web_src
|
||||
COPY web_src/package.json ./
|
||||
COPY web_src/ ./
|
||||
RUN node build.mjs
|
||||
|
||||
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
|
||||
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||
ARG VERSION
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=websrc /src/public/assets ./public/assets
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/jiang13 ./cmd/jiang13
|
||||
|
||||
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
|
||||
FROM ${IMAGE_PREFIX}alpine:3.21
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata wget su-exec \
|
||||
&& adduser -D -u 1000 -h /app jiang13
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jiang13 /app/jiang13
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chown jiang13:jiang13 /app/jiang13 \
|
||||
&& chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]
|
||||
12
LICENSE
12
LICENSE
@@ -1,6 +1,4 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 freefire
|
||||
Copyright (c) 2026 The Jiang13 Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -9,13 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
70
Makefile
70
Makefile
@@ -1,48 +1,50 @@
|
||||
# 姜十三论坛 Jiang13 Forum - Makefile
|
||||
# Go 1.26 单二进制编译,与 Gitea 打包方式一致
|
||||
# Go 1.26 单二进制:templates SSR + web_src 渐进资源(本分支无 React SPA)
|
||||
|
||||
APP_NAME := jiang13
|
||||
MAIN_PKG := ./cmd/jiang13
|
||||
BUILD_DIR := dist
|
||||
VERSION := 1.0.0
|
||||
DEV_DATA_DIR := dist/data
|
||||
VERSION := $(shell git rev-parse --short HEAD 2>/dev/null | sed 's/^/1.0.0+/' || echo 1.0.0)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||
|
||||
GO := go
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
|
||||
.PHONY: all build build-windows build-linux build-darwin build-all clean run dev tidy help web-src-build docker compose-up compose-down
|
||||
|
||||
all: build
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm install && npm run build
|
||||
web-src-build:
|
||||
cd web_src && npm run build
|
||||
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||
build: frontend-build
|
||||
build: web-src-build
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||
|
||||
## Windows amd64(先打包前端再 embed)
|
||||
build-windows: frontend-build
|
||||
## Windows amd64
|
||||
build-windows: web-src-build
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
||||
|
||||
## Linux amd64(先打包前端再 embed)
|
||||
build-linux: frontend-build
|
||||
## Linux amd64
|
||||
build-linux: web-src-build
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||
|
||||
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||
build-darwin: frontend-build
|
||||
## macOS arm64 (Apple Silicon)
|
||||
build-darwin: web-src-build
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||
|
||||
## 跨平台全量编译(frontend-build 只跑一次)
|
||||
build-all: frontend-build
|
||||
## 跨平台全量编译(web_src 只跑一次)
|
||||
build-all: web-src-build
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||
@@ -54,28 +56,40 @@ build-all: frontend-build
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
## 本地运行(仅后端,使用已 embed 的前端)
|
||||
run:
|
||||
$(GO) run $(MAIN_PKG)
|
||||
## 本地运行 SSR(先构建 web_src)
|
||||
run: web-src-build
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
$(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
|
||||
|
||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
||||
dev:
|
||||
@echo "前端热更新: http://localhost:5173"
|
||||
@echo "后端 API : http://localhost:3000"
|
||||
@trap 'kill 0' INT; \
|
||||
$(GO) run $(MAIN_PKG) & \
|
||||
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||
## 同 run(SPA 对照请 git checkout main)
|
||||
dev: run
|
||||
|
||||
## 清理编译产物
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
## 构建 Docker 镜像
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||
|
||||
## Docker Compose 启动
|
||||
compose-up:
|
||||
docker compose up -d --build
|
||||
|
||||
## Docker Compose 停止
|
||||
compose-down:
|
||||
docker compose down
|
||||
|
||||
help:
|
||||
@echo "姜十三论坛编译命令:"
|
||||
@echo " make build - 编译当前平台"
|
||||
@echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
|
||||
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
|
||||
@echo " make build - web_src + 编译当前平台"
|
||||
@echo " make build-windows - 编译 Windows"
|
||||
@echo " make build-linux - 编译 Linux"
|
||||
@echo " make build-darwin - 编译 macOS"
|
||||
@echo " make build-all - 编译全部平台"
|
||||
@echo " make run - 仅启动后端(:3000)"
|
||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||
@echo " make run / make dev - 启动 SSR(:3000)"
|
||||
@echo " make docker - 构建 Docker 镜像"
|
||||
@echo " make compose-up - Docker Compose 启动"
|
||||
@echo " make compose-down - Docker Compose 停止"
|
||||
@echo " SPA 对照: git checkout main"
|
||||
|
||||
283
README.md
283
README.md
@@ -5,14 +5,16 @@
|
||||
**能聊 · 好看 · 好装**
|
||||
|
||||
面向小圈子、团队与同好社群的轻量现代化论坛。
|
||||
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
|
||||
本分支(`rebuild/gitea-ssr`):Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
|
||||
对照 React SPA 请见 `main` 分支。
|
||||
|
||||
<br>
|
||||
|
||||
[](https://bbs.iioio.com/)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](docs/rebuild-spec/08-gitea-ssr-architecture.md)
|
||||
[](#)
|
||||
|
||||
[在线演示](https://bbs.iioio.com/) ·
|
||||
@@ -30,8 +32,8 @@
|
||||
|
||||
<br>
|
||||
|
||||
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||
> 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎提 Issue / PR 共建。
|
||||
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA)
|
||||
> 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 Issue / PR 共建。
|
||||
|
||||
</div>
|
||||
|
||||
@@ -76,7 +78,7 @@
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
||||
<br><b>富文本渲染</b><br>
|
||||
<sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
|
||||
<sub>Markdown 排版 · 图片 · 代码块 · 目录导航</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -94,28 +96,29 @@
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 |
|
||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 |
|
||||
| **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 |
|
||||
| **真 SSR** | 公开页服务端渲染完整 HTML(Go `html/template`) |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
|
||||
| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 |
|
||||
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||
| **主题切换** | 浅色 / 暗色 / 跟随系统(`localStorage`) |
|
||||
| **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 |
|
||||
|
||||
### 社区功能
|
||||
|
||||
- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员**
|
||||
- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华
|
||||
- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限
|
||||
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
|
||||
- 安装向导创建管理员;用户注册 / 登录(bcrypt + 会话 Cookie)
|
||||
- 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华
|
||||
- 帖子修订历史;可配置普通用户编辑时限
|
||||
- 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见)
|
||||
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
||||
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||
- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
|
||||
- SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||
- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||
- **单二进制** — `go:embed` 打包模板与 SSR 资源
|
||||
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||
|
||||
---
|
||||
|
||||
@@ -140,7 +143,7 @@ make build
|
||||
**手动分步(全平台):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build
|
||||
cd web_src && npm run build
|
||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
@@ -152,86 +155,154 @@ build.bat -Target build-linux
|
||||
build.bat -Target build-all
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
### 2. Docker 部署(推荐)
|
||||
|
||||
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||
**一键启动(本地构建):**
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
```bat
|
||||
.\build.bat -Target compose-up
|
||||
```
|
||||
|
||||
```bash
|
||||
make compose-up
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
|
||||
|
||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||
|
||||
**可选环境变量(容器编排):**
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
|
||||
| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
|
||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||
|
||||
JWT 自动写入 `/data/.jwt_secret`,无需 Env。
|
||||
|
||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
|
||||
```bash
|
||||
docker login
|
||||
.\build.bat -Target docker # Windows
|
||||
# make docker # Linux/macOS
|
||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或直接构建:
|
||||
|
||||
```bash
|
||||
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
|
||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down`
|
||||
|
||||
**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://docker.m.daocloud.io"]
|
||||
}
|
||||
```
|
||||
|
||||
保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。
|
||||
|
||||
**1Panel 部署提示:**
|
||||
|
||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||
2. 端口映射 `3000:3000`
|
||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/install` 完成安装
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\dist\jiang13.exe
|
||||
.\dist\jiang13.exe --data .\dist\data
|
||||
|
||||
# Linux / macOS
|
||||
./dist/jiang13
|
||||
./dist/jiang13 --data ./data
|
||||
```
|
||||
|
||||
也可先复制示例配置再改端口 / 数据目录:
|
||||
默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
|
||||
|
||||
```bash
|
||||
cp app.ini.example /opt/jiang13/app.ini
|
||||
# 编辑 app.ini 后:
|
||||
./jiang13
|
||||
```
|
||||
### 4. 首次使用
|
||||
|
||||
### 3. 首次使用
|
||||
1. 浏览器打开 `http://localhost:3000/install`
|
||||
2. 填写站点名与管理员账号
|
||||
3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
|
||||
|
||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**
|
||||
3. 登录后访问 `http://localhost:3000/admin` 进入后台
|
||||
### 配置分层(无 INI)
|
||||
|
||||
### 配置文件(`app.ini`)
|
||||
| 层 | 内容 | 需重启 |
|
||||
|----|------|--------|
|
||||
| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
|
||||
| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
|
||||
| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
|
||||
|
||||
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
|
||||
|
||||
```ini
|
||||
[server]
|
||||
HTTP_PORT = 3000
|
||||
|
||||
[paths]
|
||||
DATA = data
|
||||
|
||||
[security]
|
||||
JWT_SECRET =
|
||||
```
|
||||
|
||||
完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
|
||||
|
||||
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 启动参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
|
||||
| `--config` | `{work-path}/app.ini` | 配置文件路径 |
|
||||
| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
|
||||
| `--data` | (读配置 / `data`) | 数据目录 |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
| `--work-path` | 可执行文件目录 | 工作目录 |
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--http-addr` | (空) | 监听地址 |
|
||||
| `--data` | `data` | 数据目录 |
|
||||
| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
|
||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||
|
||||
### 4. 注册为系统服务(可选)
|
||||
**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
|
||||
|
||||
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
|
||||
|
||||
**Ubuntu / Linux(systemd,需 root):**
|
||||
### 5. 注册为系统服务(可选)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/jiang13
|
||||
sudo cp jiang13 /opt/jiang13/
|
||||
sudo /opt/jiang13/jiang13 --service install
|
||||
sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
|
||||
sudo /opt/jiang13/jiang13 --service start
|
||||
sudo systemctl enable jiang13
|
||||
```
|
||||
|
||||
**Windows(Windows Service,需管理员 PowerShell):**
|
||||
**Windows(管理员 PowerShell):**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
||||
Copy-Item .\jiang13.exe C:\jiang13\
|
||||
C:\jiang13\jiang13.exe --service install
|
||||
C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
|
||||
C:\jiang13\jiang13.exe --service start
|
||||
```
|
||||
|
||||
> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
|
||||
> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
|
||||
|
||||
---
|
||||
|
||||
@@ -239,74 +310,60 @@ C:\jiang13\jiang13.exe --service start
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
|
||||
| **渐进资源** | `web_src/`(构建到 `public/assets/`,URL `/ssr-assets/`) |
|
||||
| **构建** | `web_src` → `go:embed` templates + assets,单二进制发布 |
|
||||
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||
| **对照 SPA** | 仅 `main` 分支(React 18 · TipTap · Vite) |
|
||||
|
||||
---
|
||||
|
||||
## 前端开发
|
||||
|
||||
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
|
||||
## 本地开发(SSR)
|
||||
|
||||
```bat
|
||||
build.bat -Target dev
|
||||
build.bat -Target run
|
||||
```
|
||||
|
||||
```bash
|
||||
make dev
|
||||
make run
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||
浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`。
|
||||
|
||||
**何时需要完整构建:**
|
||||
改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`)。
|
||||
|
||||
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||
- 更新 README 界面截图 → 见下方「更新截图」
|
||||
|
||||
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||
|
||||
### 更新截图
|
||||
|
||||
默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright):
|
||||
|
||||
```bash
|
||||
npm install -D playwright
|
||||
npx playwright install chromium
|
||||
node scripts/capture-screenshots.mjs
|
||||
```
|
||||
|
||||
| 环境变量 | 说明 | 默认 |
|
||||
|----------|------|------|
|
||||
| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` |
|
||||
| `J13_POST_ID` | 详情页帖子 ID | `1` |
|
||||
| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` |
|
||||
| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` |
|
||||
|
||||
本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs`
|
||||
需要对照旧 SPA UI:`git checkout main` 或 `git worktree add ../jiang13-spa main`。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jiang13-forum/
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # app.ini 与命令行配置
|
||||
├── app.ini.example # 配置文件示例
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
├── middleware/ # JWT 鉴权等
|
||||
├── router/ # 路由注册
|
||||
├── embed_static/ # go:embed 内嵌的 SPA
|
||||
├── frontend/ # React 源码(Vite 构建)
|
||||
├── docs/screenshots/ # README 界面截图
|
||||
├── ROADMAP.md # 路线图与已知问题
|
||||
└── scripts/ # 开发辅助脚本(含截图)
|
||||
jiang13-forum/ # 分支 rebuild/gitea-ssr
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # CLI / Env 引导配置(无 INI)
|
||||
├── Dockerfile # web_src → Go → Alpine
|
||||
├── docker-compose.yml
|
||||
├── models/ # GORM 模型
|
||||
├── services/ # 业务逻辑
|
||||
├── routers/
|
||||
│ ├── setup.go # 路由总装
|
||||
│ ├── web/ # HTML SSR
|
||||
│ └── api/ # 机器入口(health / OIDC / SEO / thumb)
|
||||
├── modules/
|
||||
│ ├── auth/ # JWT / 限流
|
||||
│ ├── webrender/ # 模板渲染
|
||||
│ └── seo/
|
||||
├── templates/ # Go html/template(embed)
|
||||
├── web_src/ # 渐进 CSS/JS 源码
|
||||
├── public/assets/ # web_src 构建产物(embed)
|
||||
├── docs/rebuild-spec/ # 产品规格与 SSR 架构
|
||||
├── docs/screenshots/
|
||||
└── ROADMAP.md
|
||||
```
|
||||
|
||||
> SPA 源码树仅存在于 `main`(`frontend/`、`embed_static/`)。
|
||||
|
||||
---
|
||||
|
||||
## 数据目录
|
||||
@@ -330,10 +387,10 @@ data/
|
||||
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
|
||||
| ✅ 管理后台 | React SPA:仪表盘、置顶 / 精华、禁言、系统设置 |
|
||||
| 📋 计划中 | 通知动态优化、邮件提醒 |
|
||||
| ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 |
|
||||
| ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 |
|
||||
| ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main`) |
|
||||
| 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -347,4 +404,4 @@ data/
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||
[MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。
|
||||
|
||||
69
ROADMAP.md
69
ROADMAP.md
@@ -1,7 +1,8 @@
|
||||
# 路线图 ROADMAP
|
||||
|
||||
> 姜十三论坛仍在积极开发中,功能尚未完善。
|
||||
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
||||
> 姜十三论坛仍在积极开发中。
|
||||
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
||||
> **本仓库默认开发分支**:`rebuild/gitea-ssr`(Gitea 式 SSR)。`main` = React SPA 对照。
|
||||
|
||||
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
||||
|
||||
@@ -11,10 +12,14 @@
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||
| 评论系统 | ✅ | 换行显示已修复 |
|
||||
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(管理后台配置 ROOT_URL 与 OAuth 应用) |
|
||||
| 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 |
|
||||
| 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` |
|
||||
| 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID`) |
|
||||
| Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap) |
|
||||
| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 |
|
||||
| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfo;Admin 配置面可继续打磨 |
|
||||
|
||||
细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -28,48 +33,20 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||
| 中 | OIDC / 存储 Admin 打磨 | SSO 与 S3 热切换运维面 |
|
||||
| 低 | 编辑器增强 | 表格、表情贴纸(TipTap 不作为本分支默认) |
|
||||
| 低 | Gitea `/projects` | 仓库列表页(后置) |
|
||||
|
||||
---
|
||||
|
||||
## 🚧 进行中(In Progress)
|
||||
## ✅ 已完成(摘,本分支)
|
||||
|
||||
_当前无公开认领任务。_
|
||||
- [x] Gitea 式目录与 Go 模板 SSR
|
||||
- [x] 五种帖类型 + 核心闭环(赞/藏/门控/审核…)
|
||||
- [x] Admin 仪表盘、板块、审核、用户、徽章、媒体、设置等
|
||||
- [x] Markdown 发帖/评论编辑器与预览
|
||||
- [x] 评论嵌套树、浅色/暗色主题
|
||||
- [x] OIDC Provider 机器入口
|
||||
- [x] 本分支删除未挂载论坛 JSON API(对照 `main`)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成(Done)
|
||||
|
||||
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
|
||||
- [x] 帖子置顶(帖子详情 + 管理后台)
|
||||
- [x] 评论回复换行正确显示
|
||||
- [x] 三栏布局 + 虚拟滚动帖列表
|
||||
- [x] 浅色 / 暗色主题切换
|
||||
- [x] 移动端响应式适配
|
||||
- [x] 用户注册登录、JWT 鉴权
|
||||
- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
|
||||
- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
|
||||
- [x] 板块管理、发帖、TipTap 富文本编辑
|
||||
- [x] 帖子正文图片本地上传
|
||||
- [x] 帖子修订历史与 diff 对比
|
||||
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
|
||||
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
|
||||
- [x] 楼层式评论、引用回复、@ 高亮
|
||||
- [x] 点赞、收藏、热门帖
|
||||
- [x] 敏感词过滤、发帖限流
|
||||
- [x] 站内私信
|
||||
- [x] 回复提醒与待审提醒(站内消息 + SMTP 邮件)
|
||||
- [x] SQLite 备份、单二进制部署
|
||||
|
||||
---
|
||||
|
||||
## 如何参与
|
||||
|
||||
1. 在 Issues 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md))
|
||||
2. Fork → 分支 → PR,详见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
3. 有新想法先开 Issue 讨论,避免重复劳动
|
||||
|
||||
---
|
||||
|
||||
_最后更新:2026-08-05_
|
||||
更全清单:[`docs/rebuild-spec/02-features.md`](docs/rebuild-spec/02-features.md)。
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini)
|
||||
; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
|
||||
; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
|
||||
; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。
|
||||
|
||||
[server]
|
||||
HTTP_PORT = 3000
|
||||
|
||||
[paths]
|
||||
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
||||
DATA = data
|
||||
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||
JWT_SECRET =
|
||||
87
build.ps1
87
build.ps1
@@ -1,9 +1,10 @@
|
||||
# Jiang13 Forum - Windows build script (replaces GNU Make)
|
||||
# Usage: .\build.ps1
|
||||
# .\build.ps1 -Target build-windows
|
||||
# Branch rebuild/gitea-ssr: Go templates SSR + web_src (no React SPA)
|
||||
|
||||
param(
|
||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
|
||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||
[string]$Target = 'build'
|
||||
)
|
||||
|
||||
@@ -11,7 +12,15 @@ $ErrorActionPreference = 'Stop'
|
||||
$AppName = 'jiang13'
|
||||
$MainPkg = './cmd/jiang13'
|
||||
$BuildDir = 'dist'
|
||||
$DevDataDir = 'dist/data'
|
||||
$Version = '1.0.0'
|
||||
try {
|
||||
$gitSha = (git rev-parse --short HEAD 2>$null)
|
||||
if ($LASTEXITCODE -eq 0 -and $gitSha) {
|
||||
$Version = "1.0.0+$gitSha"
|
||||
}
|
||||
} catch {}
|
||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||
$Ldlags = "-s -w -X main.version=$Version"
|
||||
|
||||
function Ensure-Dir($path) {
|
||||
@@ -20,15 +29,12 @@ function Ensure-Dir($path) {
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Frontend {
|
||||
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
|
||||
Push-Location frontend
|
||||
function Build-WebSrc {
|
||||
Write-Host '[web_src] npm run build...' -ForegroundColor Cyan
|
||||
Push-Location web_src
|
||||
try {
|
||||
if (-not (Test-Path node_modules)) {
|
||||
npm install
|
||||
}
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'web_src build failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -67,69 +73,74 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
||||
|
||||
switch ($Target) {
|
||||
'help' {
|
||||
Write-Host '.\build.ps1 build current platform'
|
||||
Write-Host '.\build.ps1 -Target frontend frontend only'
|
||||
Write-Host '.\build.ps1 build current platform (web_src + go)'
|
||||
Write-Host '.\build.ps1 -Target web-src SSR progressive assets only'
|
||||
Write-Host '.\build.ps1 -Target build-windows'
|
||||
Write-Host '.\build.ps1 -Target build-linux'
|
||||
Write-Host '.\build.ps1 -Target build-all'
|
||||
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||
Write-Host '.\build.ps1 -Target run SSR on :3000'
|
||||
Write-Host '.\build.ps1 -Target dev same as run (SSR; SPA is on main)'
|
||||
Write-Host '.\build.ps1 -Target tidy'
|
||||
Write-Host '.\build.ps1 -Target clean'
|
||||
Write-Host '.\build.ps1 -Target docker'
|
||||
Write-Host '.\build.ps1 -Target compose-up'
|
||||
Write-Host '.\build.ps1 -Target compose-down'
|
||||
Write-Host ''
|
||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||
Write-Host 'SPA reference: git checkout main (or origin/main).'
|
||||
}
|
||||
'frontend' { Build-Frontend }
|
||||
'web-src' { Build-WebSrc }
|
||||
'tidy' { go mod tidy }
|
||||
'clean' {
|
||||
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
|
||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||
}
|
||||
'run' {
|
||||
go run $MainPkg
|
||||
Ensure-Dir $DevDataDir
|
||||
Build-WebSrc
|
||||
go run $MainPkg --work-path . --data $DevDataDir
|
||||
}
|
||||
'dev' {
|
||||
$root = (Get-Location).Path
|
||||
Write-Host ''
|
||||
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
|
||||
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
|
||||
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
|
||||
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
|
||||
Start-Process powershell -ArgumentList @(
|
||||
'-NoExit', '-Command',
|
||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg"
|
||||
) | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Push-Location frontend
|
||||
90| try {
|
||||
if (-not (Test-Path node_modules)) { npm install }
|
||||
npm run dev
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Ensure-Dir $DevDataDir
|
||||
Build-WebSrc
|
||||
Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
|
||||
go run $MainPkg --work-path . --data $DevDataDir
|
||||
}
|
||||
'build' {
|
||||
Build-Frontend
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile $AppName
|
||||
}
|
||||
'build-windows' {
|
||||
Build-Frontend
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
}
|
||||
'build-linux' {
|
||||
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||
Build-Frontend
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
}
|
||||
'build-darwin' {
|
||||
Build-Frontend
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
}
|
||||
'build-all' {
|
||||
Build-Frontend
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
||||
}
|
||||
'docker' {
|
||||
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
|
||||
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
|
||||
Write-Host '[ok] docker image built' -ForegroundColor Green
|
||||
}
|
||||
'compose-up' {
|
||||
docker compose up -d --build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
|
||||
}
|
||||
'compose-down' {
|
||||
docker compose down
|
||||
if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
kardsvc "github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
)
|
||||
|
||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
@@ -22,7 +25,7 @@ func main() {
|
||||
}
|
||||
|
||||
prg := &program{cfg: cfg}
|
||||
svc, err := service.New(prg, svcCfg)
|
||||
svc, err := kardsvc.New(prg, svcCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("创建系统服务失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
kardsvc "github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/router"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,7 +28,7 @@ type program struct {
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func (p *program) Start(s service.Service) error {
|
||||
func (p *program) Start(s kardsvc.Service) error {
|
||||
if err := p.setup(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (p *program) Start(s service.Service) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) Stop(s service.Service) error {
|
||||
func (p *program) Stop(s kardsvc.Service) error {
|
||||
log.Println("收到关机信号,正在优雅关闭...")
|
||||
if p.server == nil {
|
||||
return nil
|
||||
@@ -57,13 +57,14 @@ func (p *program) Stop(s service.Service) error {
|
||||
|
||||
func (p *program) setup() error {
|
||||
cfg := p.cfg
|
||||
cfg.Version = version
|
||||
|
||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开日志文件失败: %w", err)
|
||||
}
|
||||
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
|
||||
if service.Interactive() {
|
||||
if kardsvc.Interactive() {
|
||||
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||
} else {
|
||||
log.SetOutput(logFile)
|
||||
@@ -72,43 +73,59 @@ func (p *program) setup() error {
|
||||
|
||||
log.Println("========================================")
|
||||
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||
log.Printf(" 版本: %s", version)
|
||||
log.Println("========================================")
|
||||
|
||||
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||
if err := models.InitDB(models.DatabaseConfig{
|
||||
Type: cfg.DB.Type,
|
||||
DSN: cfg.DB.DSN,
|
||||
SQLitePath: cfg.DB.SQLitePath,
|
||||
MaxOpenConns: cfg.DB.MaxOpenConns,
|
||||
MaxIdleConns: cfg.DB.MaxIdleConns,
|
||||
ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||
}
|
||||
|
||||
engine, err := router.Setup(cfg)
|
||||
engine, err := routers.Setup(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("路由初始化失败: %w", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
addr := cfg.ListenAddr()
|
||||
p.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: engine,
|
||||
}
|
||||
|
||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
||||
log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
|
||||
log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
|
||||
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||
log.Printf("配置文件: %s", cfg.ConfigFile)
|
||||
log.Printf("数据目录: %s", cfg.DataDir)
|
||||
log.Printf("数据库: %s", cfg.DB.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
|
||||
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
|
||||
return &service.Config{
|
||||
func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
|
||||
// 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
|
||||
args := []string{
|
||||
"--work-path", cfg.WorkPath,
|
||||
"--data", cfg.DataDir,
|
||||
"--port", fmt.Sprintf("%d", cfg.Port),
|
||||
"--db-type", cfg.DB.Type,
|
||||
}
|
||||
if cfg.DB.Type == config.DBTypeSQLite {
|
||||
args = append(args, "--db-dsn", cfg.DB.SQLitePath)
|
||||
} else if cfg.DB.DSN != "" {
|
||||
args = append(args, "--db-dsn", cfg.DB.DSN)
|
||||
}
|
||||
return &kardsvc.Config{
|
||||
Name: svcName,
|
||||
DisplayName: svcDisplayName,
|
||||
Description: svcDescription,
|
||||
WorkingDirectory: cfg.WorkPath,
|
||||
Arguments: []string{
|
||||
"--work-path", cfg.WorkPath,
|
||||
"--config", cfg.ConfigFile,
|
||||
},
|
||||
Option: service.KeyValue{
|
||||
Arguments: args,
|
||||
Option: kardsvc.KeyValue{
|
||||
// systemd:异常退出后自动拉起
|
||||
"Restart": "always",
|
||||
// Windows:崩溃后重启
|
||||
@@ -117,16 +134,16 @@ func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runServiceControl(s service.Service, action string) error {
|
||||
func runServiceControl(s kardsvc.Service, action string) error {
|
||||
if action == "status" {
|
||||
st, err := s.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch st {
|
||||
case service.StatusRunning:
|
||||
case kardsvc.StatusRunning:
|
||||
fmt.Println("服务状态: 运行中 (running)")
|
||||
case service.StatusStopped:
|
||||
case kardsvc.StatusStopped:
|
||||
fmt.Println("服务状态: 已停止 (stopped)")
|
||||
default:
|
||||
fmt.Println("服务状态: 未知 (unknown)")
|
||||
@@ -134,7 +151,7 @@ func runServiceControl(s service.Service, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := service.Control(s, action); err != nil {
|
||||
if err := kardsvc.Control(s, action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
315
config/config.go
315
config/config.go
@@ -1,47 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
const (
|
||||
defaultPort = 3000
|
||||
defaultDataRel = "data"
|
||||
|
||||
DBTypeSQLite = "sqlite"
|
||||
DBTypePostgres = "postgres"
|
||||
DBTypeMySQL = "mysql"
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeS3 = "s3"
|
||||
)
|
||||
|
||||
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||
type Config struct {
|
||||
// 工作目录(默认可执行文件所在目录)
|
||||
WorkPath string
|
||||
// 配置文件绝对路径
|
||||
ConfigFile string
|
||||
// 监听端口
|
||||
Port int
|
||||
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||
DataDir string
|
||||
// JWT 签名密钥
|
||||
JWTSecret string
|
||||
// 日志文件路径
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
ServiceAction string
|
||||
// DatabaseConfig 数据库引导配置(需重启)
|
||||
type DatabaseConfig struct {
|
||||
Type string // sqlite | postgres | mysql
|
||||
DSN string // 非空则优先
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string // postgres
|
||||
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
|
||||
SQLitePath string
|
||||
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
// Parse 解析命令行与 app.ini,并初始化数据目录
|
||||
//
|
||||
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
|
||||
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
HTTPAddr string // 空表示 0.0.0.0
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
LogFile string
|
||||
ServiceAction string
|
||||
DevMode bool
|
||||
Version string // 构建版本(ldflags);用于 /ssr-assets ?v= 缓存穿透
|
||||
DB DatabaseConfig
|
||||
}
|
||||
|
||||
// Parse 解析命令行与环境变量并准备数据目录
|
||||
// 优先级:命令行显式参数 > 环境变量 > 内置默认
|
||||
func Parse() (*Config, error) {
|
||||
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
|
||||
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录")
|
||||
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
|
||||
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
|
||||
dbHostFlag := flag.String("db-host", "", "数据库主机")
|
||||
dbUserFlag := flag.String("db-user", "", "数据库用户")
|
||||
dbPassFlag := flag.String("db-pass", "", "数据库密码")
|
||||
dbNameFlag := flag.String("db-name", "", "数据库名")
|
||||
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式")
|
||||
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
|
||||
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
@@ -49,83 +81,49 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||
}
|
||||
|
||||
workPath, err := resolveWorkPath(*workFlag)
|
||||
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
|
||||
workPath, err := resolveWorkPath(workPathInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configFile, err := resolveConfigPath(workPath, *configFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
port := defaultPort
|
||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||
port = p
|
||||
}
|
||||
|
||||
fileCfg := defaultFileSettings()
|
||||
configExists := false
|
||||
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
|
||||
configExists = true
|
||||
fileCfg, err = loadAppINI(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
port := fileCfg.Port
|
||||
if *portFlag > 0 {
|
||||
port = *portFlag
|
||||
}
|
||||
|
||||
dataInput := fileCfg.DataRel
|
||||
if strings.TrimSpace(*dataFlag) != "" {
|
||||
dataInput = *dataFlag
|
||||
}
|
||||
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
|
||||
|
||||
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
|
||||
absData, err := absPath(workPath, dataInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
jwtSecret := strings.TrimSpace(*jwtFlag)
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = fileCfg.JWTSecret
|
||||
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
|
||||
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
|
||||
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
ConfigFile: configFile,
|
||||
HTTPAddr: httpAddr,
|
||||
Port: port,
|
||||
DataDir: absData,
|
||||
JWTSecret: jwtSecret,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
DB: dbCfg,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
if needDirs {
|
||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||
if !configExists {
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||
} else if action == "install" {
|
||||
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
iniJWT := fileCfg.JWTSecret
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
iniJWT = jwtSecret
|
||||
}
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
JWTSecret: iniJWT,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureDataDirs(absData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,6 +135,96 @@ func Parse() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type dbFlags struct {
|
||||
Type, DSN, Host, User, Pass, Name, SSL string
|
||||
}
|
||||
|
||||
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
|
||||
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
|
||||
switch typ {
|
||||
case "sqlite", "sqlite3":
|
||||
typ = DBTypeSQLite
|
||||
case "postgres", "postgresql", "pg":
|
||||
typ = DBTypePostgres
|
||||
case "mysql", "mariadb":
|
||||
typ = DBTypeMySQL
|
||||
default:
|
||||
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
|
||||
}
|
||||
|
||||
out := DatabaseConfig{
|
||||
Type: typ,
|
||||
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
|
||||
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
|
||||
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
|
||||
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
|
||||
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
|
||||
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
|
||||
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
|
||||
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
|
||||
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
|
||||
}
|
||||
|
||||
if typ == DBTypeSQLite {
|
||||
if out.DSN != "" {
|
||||
out.SQLitePath = out.DSN
|
||||
} else {
|
||||
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
|
||||
out.DSN = out.SQLitePath
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 1
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if out.DSN == "" {
|
||||
dsn, err := buildDSN(out)
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, err
|
||||
}
|
||||
out.DSN = dsn
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 25
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 5
|
||||
}
|
||||
if out.ConnMaxLifetimeSec == 0 {
|
||||
out.ConnMaxLifetimeSec = 300
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildDSN(c DatabaseConfig) (string, error) {
|
||||
if c.Host == "" || c.User == "" || c.Name == "" {
|
||||
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
|
||||
}
|
||||
switch c.Type {
|
||||
case DBTypePostgres:
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: c.Host,
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", c.SSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
case DBTypeMySQL:
|
||||
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
|
||||
c.User, c.Password, c.Host, c.Name), nil
|
||||
default:
|
||||
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWorkPath(flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
abs, err := filepath.Abs(flagVal)
|
||||
@@ -148,13 +236,6 @@ func resolveWorkPath(flagVal string) (string, error) {
|
||||
return defaultWorkPath()
|
||||
}
|
||||
|
||||
func resolveConfigPath(workPath, flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
return absPath(workPath, flagVal)
|
||||
}
|
||||
return filepath.Join(workPath, defaultConfName), nil
|
||||
}
|
||||
|
||||
func ensureDataDirs(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||
@@ -173,21 +254,25 @@ func ensureDataDirs(dataDir string) error {
|
||||
|
||||
func (c *Config) resolveJWT() error {
|
||||
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||
if c.JWTSecret != "" {
|
||||
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
|
||||
c.JWTSecret = string(bytesTrimSpace(data))
|
||||
return nil
|
||||
}
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||
c.JWTSecret = string(data)
|
||||
return nil
|
||||
sec, err := generateRandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.JWTSecret = generateRandomSecret(32)
|
||||
c.JWTSecret = sec
|
||||
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func validServiceAction(action string) bool {
|
||||
switch action {
|
||||
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||
@@ -197,36 +282,60 @@ func validServiceAction(action string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath 返回 SQLite 数据库文件路径
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
// ListenAddr 返回 host:port
|
||||
func (c *Config) ListenAddr() string {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Sprintf(":%d", c.Port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
|
||||
}
|
||||
|
||||
// SQLitePath 兼容旧调用:仅 sqlite 有意义
|
||||
func (c *Config) DBPath() string {
|
||||
if c.DB.Type == DBTypeSQLite {
|
||||
return c.DB.SQLitePath
|
||||
}
|
||||
return c.DB.DSN
|
||||
}
|
||||
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
}
|
||||
|
||||
// PostImageUploadDir 返回帖子正文图片上传目录
|
||||
func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
|
||||
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||
func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
|
||||
// FilterWordsPath 返回敏感词配置文件路径
|
||||
func (c *Config) FilterWordsPath() string {
|
||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||
}
|
||||
|
||||
func generateRandomSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
func generateRandomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("生成密钥失败: %w", err)
|
||||
}
|
||||
return string(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envIntDefault(key string, def int) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
41
config/env.go
Normal file
41
config/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 引导环境变量(无 INI)
|
||||
const (
|
||||
envWorkPath = "JIANG13_WORK_PATH"
|
||||
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||
envHTTPAddr = "JIANG13_HTTP_ADDR"
|
||||
envData = "JIANG13_DATA"
|
||||
envDBType = "JIANG13_DB_TYPE"
|
||||
envDBDSN = "JIANG13_DB_DSN"
|
||||
envDBHost = "JIANG13_DB_HOST"
|
||||
envDBUser = "JIANG13_DB_USER"
|
||||
envDBPass = "JIANG13_DB_PASS"
|
||||
envDBName = "JIANG13_DB_NAME"
|
||||
envDBSSLMode = "JIANG13_DB_SSLMODE"
|
||||
envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
|
||||
envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
|
||||
envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
|
||||
)
|
||||
|
||||
func envOrDefault(key string) string {
|
||||
return strings.TrimSpace(os.Getenv(key))
|
||||
}
|
||||
|
||||
func envIntOrZero(key string) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
107
config/ini.go
107
config/ini.go
@@ -1,107 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 3000
|
||||
defaultDataRel = "data"
|
||||
defaultConfName = "app.ini"
|
||||
)
|
||||
|
||||
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
||||
type fileSettings struct {
|
||||
Port int
|
||||
DataRel string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
func defaultFileSettings() fileSettings {
|
||||
return fileSettings{
|
||||
Port: defaultPort,
|
||||
DataRel: defaultDataRel,
|
||||
}
|
||||
}
|
||||
|
||||
func loadAppINI(path string) (fileSettings, error) {
|
||||
out := defaultFileSettings()
|
||||
cfg, err := ini.LoadSources(ini.LoadOptions{
|
||||
IgnoreInlineComment: true,
|
||||
}, path)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("server"); err == nil {
|
||||
if k := sec.Key("HTTP_PORT"); k.String() != "" {
|
||||
p, err := k.Int()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("server.HTTP_PORT 无效: %w", err)
|
||||
}
|
||||
if p <= 0 || p > 65535 {
|
||||
return out, fmt.Errorf("server.HTTP_PORT 超出范围: %d", p)
|
||||
}
|
||||
out.Port = p
|
||||
}
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("paths"); err == nil {
|
||||
if v := strings.TrimSpace(sec.Key("DATA").String()); v != "" {
|
||||
out.DataRel = v
|
||||
}
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("security"); err == nil {
|
||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
|
||||
func writeAppINI(path string, s fileSettings) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini)\n")
|
||||
b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
|
||||
b.WriteString("; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。\n")
|
||||
b.WriteString(";\n")
|
||||
b.WriteString("; 默认位置:程序工作目录下的 app.ini\n")
|
||||
b.WriteString("; 可用 --config / --work-path 覆盖。\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString("[server]\n")
|
||||
b.WriteString("HTTP_PORT = ")
|
||||
b.WriteString(strconv.Itoa(s.Port))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("[paths]\n")
|
||||
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
|
||||
b.WriteString("DATA = ")
|
||||
b.WriteString(s.DataRel)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("[security]\n")
|
||||
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
|
||||
b.WriteString("JWT_SECRET = ")
|
||||
b.WriteString(s.JWTSecret)
|
||||
b.WriteString("\n")
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
// resolveDataRelForINI 把绝对数据目录尽量写成相对工作目录的路径,便于 app.ini 可读
|
||||
func resolveDataRelForINI(workPath, absData string) string {
|
||||
rel, err := filepath.Rel(workPath, absData)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return absData
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# 姜十三论坛 — Docker Compose 单服务部署
|
||||
# 启动:docker compose up -d --build
|
||||
# 或:make compose-up / build.bat -Target compose-up
|
||||
#
|
||||
# 默认 SQLite(数据在 /data)。换库示例:
|
||||
# JIANG13_DB_TYPE=postgres
|
||||
# JIANG13_DB_DSN=postgres://forum:secret@host:5432/jiang13?sslmode=disable
|
||||
# JIANG13_DB_TYPE=mysql
|
||||
# JIANG13_DB_DSN=forum:secret@tcp(host:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4
|
||||
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# JIANG13_DB_TYPE: sqlite
|
||||
# JIANG13_HTTP_PORT: "3000"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
jiang13-data:
|
||||
15
docker-entrypoint.sh
Normal file
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
|
||||
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R jiang13:jiang13 "$DATA_DIR"
|
||||
exec su-exec jiang13 /app/jiang13 "$@"
|
||||
fi
|
||||
|
||||
exec /app/jiang13 "$@"
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
|
||||
|
||||
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA(单页应用)通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。
|
||||
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**,`web_src` 做渐进增强 CSS/JS(`go:embed`),数据库默认 **SQLite**,拷贝到服务器就能跑。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,24 +60,24 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
||||
|
||||
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
|
||||
|
||||
### 发帖:富文本,够用就好
|
||||
### 发帖:Markdown,够用就好
|
||||
|
||||
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff(差异)对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
||||
发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
||||
|
||||
### 讨论:楼层回复,聊得清楚
|
||||
|
||||
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
||||
评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
||||
|
||||
### 管理:后台也是 SPA
|
||||
### 管理:SSR 后台
|
||||
|
||||
管理后台统一在 `/admin`,与前台同一套 React 体验:
|
||||
管理后台统一在 `/admin`,与公开页同一套模板布局:
|
||||
|
||||
- 仪表盘、板块与帖子管理
|
||||
- 用户禁言、删帖删评
|
||||
- 论坛参数、限流、敏感词
|
||||
- SQLite **一键备份**
|
||||
|
||||
权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。
|
||||
权限模型很简单:**普通用户 / 管理员**;首次通过 **安装向导** 创建管理员账号。
|
||||
|
||||
---
|
||||
|
||||
@@ -87,19 +87,19 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
||||
|
||||
```text
|
||||
编译 → 得到一个 jiang13(或 jiang13.exe)
|
||||
放到目录里运行 → 自动生成 app.ini
|
||||
打开浏览器注册 → 第一个账号就是管理员
|
||||
放到目录里运行 → Env / CLI 引导(默认 SQLite)
|
||||
打开浏览器完成安装向导 → 创建管理员
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
|
||||
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
|
||||
- **app.ini 配置**:风格类似 Gitea,端口与数据目录一眼能改;业务项在管理后台配置;
|
||||
- **Env / CLI 引导**:端口、数据目录、数据库类型;业务项在管理后台热更新;
|
||||
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
|
||||
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
|
||||
|
||||
典型启动后访问 `http://localhost:3000`,注册即可开始。
|
||||
典型启动后访问 `http://localhost:3000`,完成安装后即可开始。
|
||||
|
||||
---
|
||||
|
||||
@@ -107,26 +107,26 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go · Gin · GORM · SQLite |
|
||||
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| 构建 | Vite 构建 SPA,再由 `go:embed` 打进二进制 |
|
||||
| 认证 | bcrypt + JWT Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
||||
| 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` |
|
||||
| 渐进资源 | `web_src` → `public/assets`(`/ssr-assets/`) |
|
||||
| 构建 | `web_src` + `go:embed` templates/assets,单二进制 |
|
||||
| 认证 | bcrypt + 会话 Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
||||
|
||||
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR(热模块替换)可秒级预览前端改动。
|
||||
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`。
|
||||
|
||||
---
|
||||
|
||||
## 现在走到哪一步
|
||||
|
||||
项目仍在积极开发中,核心体验已经可用:
|
||||
项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`):
|
||||
|
||||
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序
|
||||
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史
|
||||
- ✅ React 管理后台与论坛参数配置
|
||||
- ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页
|
||||
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史(Markdown 编辑器)
|
||||
- ✅ SSR 管理后台与论坛参数配置
|
||||
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
|
||||
- ✅ 单二进制部署与系统服务
|
||||
|
||||
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。
|
||||
计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Issue 预填模板
|
||||
|
||||
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
|
||||
以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**(`rebuild/gitea-ssr`);对照 SPA 请注明 `main`。
|
||||
|
||||
---
|
||||
|
||||
## Issue #1 · 评论回复换行不显示
|
||||
## Issue · Bug 模板
|
||||
|
||||
**标题:** `[Bug] 评论回复换行不显示`
|
||||
**标题:** `[Bug] 简短描述`
|
||||
|
||||
**标签:** `bug` `ui/ux`
|
||||
|
||||
@@ -14,79 +14,52 @@
|
||||
|
||||
### 问题描述
|
||||
|
||||
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
|
||||
(一句话)
|
||||
|
||||
### 复现步骤
|
||||
|
||||
1. 打开任意帖子详情页(如 `/post/2`)
|
||||
2. 在底部评论框输入:
|
||||
```
|
||||
第一行
|
||||
第二行
|
||||
第三行
|
||||
```
|
||||
3. 点击发送
|
||||
4. 查看刚发布的评论
|
||||
1. …
|
||||
2. …
|
||||
|
||||
### 期望行为
|
||||
### 期望行为 / 实际行为
|
||||
|
||||
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
|
||||
### 相关代码(若已知)
|
||||
|
||||
### 实际行为
|
||||
|
||||
多行内容被渲染成单行连续文字。
|
||||
|
||||
### 相关代码
|
||||
|
||||
- `frontend/src/components/CommentContent.tsx`
|
||||
- `frontend/src/utils/content.ts`(`highlightMentions` 中的 `\n` → `<br>` 转换)
|
||||
- `frontend/src/styles/global.css`(`.floor-body` 的 `white-space: pre-wrap`)
|
||||
|
||||
### 可能原因
|
||||
|
||||
- 仅处理了 `\n`,未处理 Windows 的 `\r\n`
|
||||
- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常
|
||||
- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查)
|
||||
- 模板:`templates/…`
|
||||
- 路由:`routers/web/…`
|
||||
- 样式/脚本:`web_src/…`
|
||||
|
||||
### 环境
|
||||
|
||||
- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版)
|
||||
- 浏览器:Chrome / Edge 最新版
|
||||
- 分支:`rebuild/gitea-ssr`(或 `main` SPA)
|
||||
- 浏览器 / OS
|
||||
|
||||
---
|
||||
|
||||
## Issue #2 · 示例:管理能力扩展(模板文案)
|
||||
## Issue · 功能增强模板
|
||||
|
||||
**标题:** `[Feature] 管理后台增加某某能力`
|
||||
**标题:** `[Feature] 简短描述`
|
||||
|
||||
**标签:** `enhancement` `ui/ux` `good first issue`
|
||||
**标签:** `enhancement`
|
||||
|
||||
**正文:**
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
|
||||
|
||||
### 现状
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 数据模型与业务逻辑 | ✅ / ❌ |
|
||||
| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ |
|
||||
| React 管理后台入口 | ✅ / ❌ |
|
||||
| React 前台操作入口(如适用) | ✅ / ❌ |
|
||||
| 规格(`docs/rebuild-spec`) | ✅ / ❌ |
|
||||
| SSR 页面 / 表单 | ✅ / ❌ |
|
||||
| 业务 `services/` | ✅ / ❌ |
|
||||
|
||||
### 期望方案
|
||||
|
||||
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见)
|
||||
2. 调用已有或新增的 `/api/admin/*` JSON API
|
||||
3. 成功后刷新列表/详情,无需离开当前页面
|
||||
1. …
|
||||
2. …
|
||||
|
||||
### 相关代码
|
||||
|
||||
- 后端:`service/`、`handler/api.go`、`router/router.go`
|
||||
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts`
|
||||
|
||||
### 备注
|
||||
|
||||
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。
|
||||
- `routers/web/`、`templates/`、`services/`
|
||||
- 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md`
|
||||
|
||||
185
docs/rebuild-spec/01-product.md
Normal file
185
docs/rebuild-spec/01-product.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 01 · 产品定位与模块地图
|
||||
|
||||
> **读者**:重构架构师 / 产品对齐
|
||||
> **前置**:[README.md](README.md)
|
||||
> **后续**:[02-features.md](02-features.md)
|
||||
> **源码**:[docs/introduction.md](../introduction.md)、[README.md](../../README.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位
|
||||
|
||||
姜十三论坛不做大而全公网社区,只服务「几人到几百人」的内部交流:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||
| 项目配套社区 | 可与 Gitea 通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||
| 个人站长 | 希望数据自管、部署简单 |
|
||||
|
||||
**产品口号气质**:能聊 · 好看 · 好装(部署简单)。
|
||||
|
||||
**规模预期**:非百万用户级;信息密度接近 V2EX / NGA 一类,而非大留白营销站。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品 vs 运维(拆开看待)
|
||||
|
||||
| 维度 | 当前实现 | 重构时 |
|
||||
|------|----------|--------|
|
||||
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
|
||||
| **运维** | 单二进制 + Env 引导 + SQLite/PG/MySQL + `forum_settings` 热更 | **本分支已落地**;无 `app.ini` |
|
||||
|
||||
规格文档把「用户能做什么」写死;把「怎么打包发布」放在 [07-config-ops.md](07-config-ops.md) 供参考。
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色模型
|
||||
|
||||
| 角色 | 识别 | 能力摘要 |
|
||||
|------|------|----------|
|
||||
| **游客** | 未登录 | 浏览公开内容;可发表游客评论(需昵称等);门控内容按规则遮盖 |
|
||||
| **登录用户** | JWT Cookie 有效且未禁言 | 发帖、评论、点赞收藏、私信、签到抽奖、友链申请、举报等 |
|
||||
| **认证用户** | `user.verified = true` | 与管理员一样:**发帖/评论免审**(`SkipsModeration`) |
|
||||
| **管理员** | `role = admin` | 全部后台能力;免审;看待审/被拒内容 |
|
||||
| **系统** | 私信 `from_user_id = 0` | 发系统通知(审核、回复提醒、举报结果等) |
|
||||
|
||||
**引导规则**:站点**第一个注册用户自动成为管理员**(无安装向导单独建管步骤)。
|
||||
|
||||
禁言用户(`banned`):带鉴权的写接口被拒绝;浏览策略以实现为准,前端通常视为不可正常互动。
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块地图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph core [核心社区]
|
||||
auth[认证与账号]
|
||||
board[板块]
|
||||
feed[Feed与搜索]
|
||||
post[帖子与特殊类型]
|
||||
comment[评论]
|
||||
gate[内容门控]
|
||||
end
|
||||
subgraph social [社交]
|
||||
like[点赞收藏]
|
||||
msg[私信与通知]
|
||||
profile[用户主页]
|
||||
report[举报]
|
||||
end
|
||||
subgraph economy [经济与成长]
|
||||
points[积分钱包]
|
||||
checkin[签到抽奖]
|
||||
badge[徽章]
|
||||
level[等级Exp]
|
||||
end
|
||||
subgraph site [站点扩展]
|
||||
page[自定义单页]
|
||||
links[友情链接]
|
||||
gitea[Gitea码桶]
|
||||
brand[品牌与SEO]
|
||||
end
|
||||
subgraph admin [管理与集成]
|
||||
mod[审核回收站]
|
||||
settings[系统设置]
|
||||
oidc[OIDC Provider]
|
||||
storage[本地或S3存储]
|
||||
mail[SMTP邮件]
|
||||
end
|
||||
auth --> feed
|
||||
board --> feed
|
||||
feed --> post
|
||||
post --> comment
|
||||
post --> gate
|
||||
post --> like
|
||||
comment --> msg
|
||||
points --> gate
|
||||
points --> checkin
|
||||
auth --> oidc
|
||||
settings --> mail
|
||||
settings --> storage
|
||||
```
|
||||
|
||||
### 4.1 认证与账号
|
||||
|
||||
- 注册 / 登录 / 登出;可选邮箱验证码;忘记密码重置
|
||||
- 图形验证码(注册场景)
|
||||
- 个人资料:昵称、签名、头像、改密
|
||||
- 邮件未配置时可能关闭公开注册(见业务规则)
|
||||
|
||||
### 4.2 板块与 Feed
|
||||
|
||||
- 多板块;图标与色板;排序
|
||||
- 首页「全部」+ `/board/:id`
|
||||
- 排序:最新发帖 / 最新回复 / 热门
|
||||
- 搜索:关键词、标签、作者、仅标题
|
||||
- 列表样式:仅标题 / 摘要 / 缩略图(后台可配)
|
||||
|
||||
### 4.3 帖子
|
||||
|
||||
- 富文本正文(HTML,TipTap 产出)+ 可选 Markdown 编辑面
|
||||
- 标签、修订历史与 diff
|
||||
- 五种类型:`normal` | `question` | `poll` | `bounty` | `lottery`
|
||||
- 运营标记:全局置顶、版内置顶、精华、禁止编辑、禁止评论
|
||||
- 审核状态:`pending` | `published` | `rejected`;软删回收站
|
||||
|
||||
### 4.4 内容门控
|
||||
|
||||
正文内嵌自定义标签(非独立表行,存在 `posts.content` HTML 中):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| `<members-only>` | 登录可见 |
|
||||
| `<reply-only>` | 本帖已回复可见 |
|
||||
| `<points-only data-cost="N">` | 积分解锁;按块计费 |
|
||||
|
||||
### 4.5 评论
|
||||
|
||||
- 楼层号;回复指定楼;嵌套展示;引用;@ 提及
|
||||
- 游客评论字段;私密评论(仅相关人可见)
|
||||
- 点赞、编辑时限、审核、软删
|
||||
|
||||
### 4.6 积分经济与成长
|
||||
|
||||
- **Points**:可消费积分(签到、抽奖、解锁、悬赏托管等)
|
||||
- **Exp**:不可消费经验 → 等级 Lv1–10
|
||||
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
|
||||
- 徽章:自动(门槛)+ 限定(管理员发放)
|
||||
|
||||
### 4.7 私信与通知
|
||||
|
||||
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
|
||||
|
||||
### 4.8 友链与站点页
|
||||
|
||||
- 管理员维护品牌友链 JSON;用户申请审核(可选回链检测)
|
||||
- 自定义单页(关于、版规等):slug、发布、导航/页脚展示
|
||||
|
||||
### 4.9 Gitea 开源码桶
|
||||
|
||||
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
|
||||
|
||||
### 4.10 OIDC Provider
|
||||
|
||||
本站作为 IdP:Discovery / Authorize / Token / UserInfo / Logout / JWKS;多 OAuth 客户端。
|
||||
|
||||
### 4.11 管理后台
|
||||
|
||||
仪表盘、板块、单页、友链、帖/评、举报、用户、徽章、媒体、系统设置、SQLite 备份。
|
||||
|
||||
### 4.12 SEO / 发现
|
||||
|
||||
`robots.txt`、`sitemap.xml`、Open Graph / Twitter / JSON-LD;当前另有爬虫 HTML。新站应用 SSR 统一,但 **meta 字段集合应保留**(见 [07-config-ops.md](07-config-ops.md))。
|
||||
|
||||
---
|
||||
|
||||
## 5. 权限一句话
|
||||
|
||||
- **读公开内容**:人人可(含游客)
|
||||
- **写内容**:登录(部分评论允许游客)
|
||||
- **审与运营**:管理员
|
||||
- **免审写**:管理员或 `verified`
|
||||
|
||||
细节状态机见 [05-business-rules.md](05-business-rules.md)。
|
||||
215
docs/rebuild-spec/02-features.md
Normal file
215
docs/rebuild-spec/02-features.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 02 · 功能清单(验收级)
|
||||
|
||||
> **读者**:实现与验收
|
||||
> **前置**:[01-product.md](01-product.md)
|
||||
> **交叉**:[05-business-rules.md](05-business-rules.md)、[06-pages-ux.md](06-pages-ux.md)
|
||||
> **源码对照**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
|
||||
|
||||
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
|
||||
|
||||
---
|
||||
|
||||
## A. 浏览与布局
|
||||
|
||||
- [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏)
|
||||
- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — CSS `prefers-color-scheme` + `data-theme` 显式覆盖;顶栏循环:跟随系统 / 浅色 / 暗色(`j13-theme`)
|
||||
- [ ] 响应式:平板/手机收起侧栏
|
||||
- [ ] 长列表虚拟滚动或等价流畅方案
|
||||
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
||||
- [x] 板块筛选:全部 + 单板块
|
||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
||||
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
||||
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器
|
||||
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`)
|
||||
- [ ] 下拉刷新(移动端)
|
||||
- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
|
||||
- [x] 404 页
|
||||
|
||||
---
|
||||
|
||||
## B. 认证与个人中心
|
||||
|
||||
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
|
||||
- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验
|
||||
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
||||
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
|
||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
||||
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
||||
- [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做)
|
||||
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
|
||||
- [x] 公开用户主页 `/user/:id`(无邮箱)
|
||||
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
||||
|
||||
---
|
||||
|
||||
## C. 板块
|
||||
|
||||
- [x] 列出板块(含帖数等展示字段)— SSR `/boards` + 侧栏 + Admin
|
||||
- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
|
||||
- [x] 板块名称、描述、图标、色板索引、排序
|
||||
- [x] 默认板块保障(空站可引导创建)
|
||||
|
||||
---
|
||||
|
||||
## D. 帖子(通用)
|
||||
|
||||
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
|
||||
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
|
||||
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强
|
||||
- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
|
||||
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
||||
- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`)
|
||||
- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff)
|
||||
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
|
||||
- [x] 浏览量 — 详情页计数
|
||||
- [x] 举报帖子 — SSR 表单 + `/admin/reports`
|
||||
- [x] 内容审核状态展示(作者可见待审/被拒)— SSR 帖详情横幅 + 评论楼层标签
|
||||
|
||||
### D.1 帖子类型
|
||||
|
||||
- [x] `normal` 普通讨论 — SSR compose 默认
|
||||
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
|
||||
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
||||
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund
|
||||
- [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw
|
||||
|
||||
### D.2 运营标记(管理员)
|
||||
|
||||
- [x] 全局置顶 / 取消 — SSR 帖详情
|
||||
- [x] 版内置顶 / 取消(仅板块列表抬升)— SSR 帖详情
|
||||
- [x] 精华 / 取消 — SSR 帖详情
|
||||
- [x] 禁止编辑(edit lock)— SSR 帖详情
|
||||
- [x] 禁止评论 / 结贴(comments lock)— SSR 帖详情
|
||||
- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
|
||||
- [x] 回收站:恢复 / 彻底删除 — SSR `/admin/trash`
|
||||
|
||||
---
|
||||
|
||||
## E. 内容门控
|
||||
|
||||
- [x] 编辑器可插入「登录可见」块 — compose 工具栏
|
||||
- [x] 编辑器可插入「回复可见」块 — compose 工具栏
|
||||
- [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt
|
||||
- [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI
|
||||
- [x] 已登录未回复:遮盖 reply-only(作者与管理员始终可见)
|
||||
- [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML
|
||||
- [x] 搜索 / SEO 出口对门控内容做红action,不泄露正文(既有)
|
||||
|
||||
---
|
||||
|
||||
## F. 评论
|
||||
|
||||
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进
|
||||
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
|
||||
- [ ] 游客评论(公开接口可写,字段 guest_*)
|
||||
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
|
||||
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
|
||||
- [x] 评论举报 — SSR
|
||||
- [x] @ 提及 → 通知 — SSR 发评/审通调用 `NotifyCommentMentions`
|
||||
- [x] 回复提醒(站内信 + 可选邮件)— SSR 发评/审通调用 `NotifyCommentPublished`
|
||||
- [x] 审核中 / 被拒评论可见性规则 — 服务层已有;SSR 楼层标签(作者/管理员)
|
||||
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
|
||||
|
||||
---
|
||||
|
||||
## G. 私信与通知
|
||||
|
||||
- [x] 会话列表(含系统会话 peer=0)— SSR `/messages`
|
||||
- [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
|
||||
- [x] 发送私信 — 表单 PRG;用户主页「发私信」入口
|
||||
- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
|
||||
- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
|
||||
- [x] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;`/messages/with/0?kind=` 筛选)
|
||||
|
||||
---
|
||||
|
||||
## H. 积分、签到、抽奖、徽章、等级
|
||||
|
||||
- [x] 积分流水查询 — SSR `/profile` 钱包近 N 条
|
||||
- [x] 每日签到:基础 5,连签每日 +1,封顶 15 — `POST /profile/checkin`
|
||||
- [x] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 — `POST /profile/lottery`
|
||||
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
|
||||
- [x] 短龄同 IP 互刷拒绝分成 — `UnlockPointsBlock` / `suspiciousUnlockPair`(双方注册未满 7 天且 LastLoginIP 相同)
|
||||
- [x] Exp → 等级 Lv1–10 — 门槛推导 + 发帖/评论/获赞加 Exp;Admin 设等级 SSR `/admin/users`
|
||||
- [x] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子 + `EvaluateAuto`(用户页/资料页访问时评估);无独立定时调度 UI
|
||||
- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
|
||||
- [x] 管理员调整积分、设定认证(免审)、设定等级 — SSR `/admin/users`
|
||||
|
||||
---
|
||||
|
||||
## I. 友链
|
||||
|
||||
- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links`
|
||||
- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply`
|
||||
- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo`
|
||||
- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提)
|
||||
- [x] 管理员审核:通过 / 拒绝 / 回链复检 — SSR `/admin/friend-links`(创建时可异步检测)
|
||||
- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删
|
||||
|
||||
---
|
||||
|
||||
## J. 站点单页
|
||||
|
||||
- [x] 公开:`/page/:slug` 列表入口(nav/footer)— SSR
|
||||
- [x] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 — SSR `/admin/pages`
|
||||
|
||||
---
|
||||
|
||||
## K. Gitea 码桶(**后置**)
|
||||
|
||||
> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
|
||||
|
||||
- [ ] (后置)后台开关、Base URL、Token、同步间隔
|
||||
- [ ] (后置)手动同步 + 后台定时同步
|
||||
- [ ] (后置)前台 `/projects` 列表与搜索
|
||||
|
||||
---
|
||||
|
||||
## L. OIDC Provider
|
||||
|
||||
- [ ] Discovery、JWKS、Authorize、Token、UserInfo、Logout
|
||||
- [ ] 多 OAuth 客户端 CRUD;密钥哈希存储;PKCE 字段支持
|
||||
- [ ] groups claim 映射 admin/user 组
|
||||
|
||||
---
|
||||
|
||||
## M. 媒体与存储
|
||||
|
||||
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁
|
||||
- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选
|
||||
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化
|
||||
- [x] 管理后台媒体列表与删除 — SSR `/admin/media`
|
||||
- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」
|
||||
|
||||
---
|
||||
|
||||
## N. 管理后台其它
|
||||
|
||||
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
|
||||
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
|
||||
- [x] 基础限流(post/comment/register/login/window)— SSR `/admin/settings`
|
||||
- [x] 完整 Limits(字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits`
|
||||
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
|
||||
- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
|
||||
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`<head>` favicon + og:image
|
||||
- [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite;写入 data 目录后下载)
|
||||
|
||||
---
|
||||
|
||||
## O. 基础设施
|
||||
|
||||
- [x] `GET /health` — `routers/setup.go`
|
||||
- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
|
||||
- [x] 静态上传文件可达 — `Static /uploads` → data/uploads
|
||||
- [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`
|
||||
|
||||
---
|
||||
|
||||
## 明确不在当前规格内(计划中可后做)
|
||||
|
||||
摘自 [`ROADMAP.md`](../../ROADMAP.md),**不是**现网必交验收项:
|
||||
|
||||
- 通知动态 UX 大幅优化
|
||||
- 帖子搜索增强(组合筛选更强)
|
||||
|
||||
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。
|
||||
473
docs/rebuild-spec/03-data-model.md
Normal file
473
docs/rebuild-spec/03-data-model.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# 03 · 数据模型
|
||||
|
||||
> **读者**:实现数据库与领域层的 AI
|
||||
> **前置**:[01-product.md](01-product.md)
|
||||
> **后续**:[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md)
|
||||
> **源码**:[`model/models.go`](../../models/models.go)、[`model/oauth.go`](../../models/oauth.go)、[`model/gitea.go`](../../models/gitea.go)、[`model/level.go`](../../models/level.go)、[`model/db.go`](../../models/db.go)、[`model/user_view.go`](../../models/user_view.go)、[`service/settings.go`](../../services/settings.go)
|
||||
|
||||
当前无独立 SQL migration;表由 GORM `AutoMigrate` 创建。新站可用正式 migration,但**字段语义应对齐**。
|
||||
|
||||
---
|
||||
|
||||
## 1. ER 概览
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
User ||--o{ Post : authors
|
||||
User ||--o{ Comment : authors
|
||||
Board ||--o{ Post : contains
|
||||
Post ||--o{ Comment : has
|
||||
Post ||--o{ PostLike : likes
|
||||
Post ||--o{ PostFavorite : favorites
|
||||
Post ||--o{ PostRevision : revisions
|
||||
Comment ||--o{ CommentLike : likes
|
||||
Comment ||--o{ CommentRevision : revisions
|
||||
Post ||--o| Poll : poll
|
||||
Poll ||--o{ PollOption : options
|
||||
PollOption ||--o{ PollVote : votes
|
||||
Post ||--o{ PostLotteryWinner : winners
|
||||
Post ||--o{ PostContentUnlock : unlocks
|
||||
User ||--o{ PointLedger : ledger
|
||||
User ||--o{ CheckIn : checkins
|
||||
User ||--o{ LotteryDraw : draws
|
||||
User ||--o{ UserBadge : earns
|
||||
BadgeDef ||--o{ UserBadge : defines
|
||||
User ||--o{ PrivateMessage : sends
|
||||
User ||--o{ PostReport : reports
|
||||
User ||--o{ FriendLinkApply : applies
|
||||
User ||--o{ Media : uploads
|
||||
```
|
||||
|
||||
另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 表与字段
|
||||
|
||||
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
|
||||
|
||||
### 2.0 sessions
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| id | string(64) PK | 密码学随机 opaque id(Cookie `jiang13_session` 的值) |
|
||||
| user_id | uint index | 用户 |
|
||||
| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
|
||||
| created_at / last_seen_at | time | |
|
||||
| ip / user_agent | string | 可选审计 |
|
||||
|
||||
登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
|
||||
|
||||
### 2.1 users
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | uint PK | | |
|
||||
| username | string(128) | unique, not null | 登录名 |
|
||||
| email | string(128) | index, default '' | 公开主页不返回 |
|
||||
| password | string(128) | not null | bcrypt 哈希,永不返回 |
|
||||
| nickname | string(64) | | 展示名 |
|
||||
| signature | string(512) | default '' | 个人签名 |
|
||||
| avatar | string(512) | | 相对或绝对 URL |
|
||||
| role | string(16) | default `user` | `user` \| `admin` |
|
||||
| verified | bool | index | 站长认证,免审 |
|
||||
| exp | int | default 0 | 经验(不可消费) |
|
||||
| points | int | default 0 | 可用积分 |
|
||||
| creator_income_total | int | default 0 | 创作分成累计 |
|
||||
| banned | bool | | 禁言 |
|
||||
| banned_at | *time | | |
|
||||
| last_login_at | *time | json 隐藏 | |
|
||||
| last_login_ip | string(45) | json 隐藏 | |
|
||||
| last_access_at | *time | json 隐藏 | 带鉴权访问 |
|
||||
| created_at / updated_at | time | | |
|
||||
| deleted_at | soft | | |
|
||||
|
||||
**非落库展示字段**:`level`(由 Exp 推导)、`badges`(附加)。
|
||||
|
||||
视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../models/user_view.go))。
|
||||
|
||||
### 2.2 boards
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| id, name(64), description(512) | |
|
||||
| icon(64), color_index(default -1) | -1=按 id 自动取色 |
|
||||
| sort_order | 升序 |
|
||||
| created_at, updated_at, deleted_at | |
|
||||
|
||||
### 2.3 posts
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| id, board_id, user_id | FK 索引 |
|
||||
| title(256), content(text) | HTML 正文 |
|
||||
| content_plain(text) | 纯文本搜索索引,`json:"-"` |
|
||||
| tags(256) | 逗号或空格分隔标签串 |
|
||||
| post_type | `normal`\|`question`\|`poll`\|`bounty`\|`lottery` |
|
||||
| question_resolved | 仅问答 |
|
||||
| bounty_points, bounty_status, bounty_comment_id | 悬赏 |
|
||||
| lottery_winner_count, lottery_status | 抽奖帖 |
|
||||
| pinned | 全局置顶 |
|
||||
| board_pinned | 版内置顶 |
|
||||
| featured | 精华 |
|
||||
| edit_locked | 禁止编辑 |
|
||||
| comments_locked | 禁止新评论 |
|
||||
| status | `pending`\|`published`\|`rejected` |
|
||||
| like_count, view_count | |
|
||||
| timestamps + soft delete | |
|
||||
|
||||
关联:Board, User, Comments。
|
||||
|
||||
### 2.4 post_revisions
|
||||
|
||||
每次修改前保存旧版:`post_id`, `editor_id`, `title`, `content`, `tags`, `created_at`。
|
||||
|
||||
### 2.5 comments
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| post_id, user_id | user_id=0 表示游客 |
|
||||
| floor | 楼层号 |
|
||||
| content | HTML/富文本 |
|
||||
| reply_to | *uint 回复目标评论 |
|
||||
| guest_nick / guest_email / guest_url | 游客信息 |
|
||||
| is_private | 私密评论 |
|
||||
| status | pending\|published\|rejected |
|
||||
| like_count | |
|
||||
| soft delete | |
|
||||
|
||||
**非落库**:`reply_target`, `thread_parent_id`, `content_hidden`, `liked`。
|
||||
|
||||
### 2.6 comment_revisions
|
||||
|
||||
`comment_id`, `editor_id`, `content`, `created_at`(管理员可查)。
|
||||
|
||||
### 2.7 post_likes / comment_likes / post_favorites
|
||||
|
||||
唯一索引:(post_id|comment_id, user_id)。收藏带 Post 关联。
|
||||
|
||||
### 2.8 private_messages
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| from_user_id | 0=系统 |
|
||||
| to_user_id | |
|
||||
| subject(256), content(text) | |
|
||||
| kind | 见枚举 |
|
||||
| related_post_id, related_report_id | 可选 |
|
||||
| is_read | |
|
||||
| created_at | |
|
||||
|
||||
### 2.9 post_reports
|
||||
|
||||
帖或评举报:`post_id` 必填;`comment_id` 有值则为评论举报。
|
||||
`reason`, `detail`, `status`, `handler_id`, `handle_note`, `handled_at`。
|
||||
|
||||
### 2.10 friend_link_applies
|
||||
|
||||
申请字段:name, url, description, logo, reciprocal_page_url, link_on_homepage,
|
||||
reciprocal_verified / check_note / checked_at, status, review_note, reviewed_at + soft delete。
|
||||
|
||||
### 2.11 media
|
||||
|
||||
上传索引:`category`=`avatars`\|`posts`\|`site`;`name`, `url`(unique), `size`, `content_type`, `storage_type`=`local`\|`s3`, `user_id`。
|
||||
|
||||
### 2.12 point_ledgers
|
||||
|
||||
`user_id`, `delta`, `balance`(变动后), `reason`, `ref_type`, `ref_id`, `note`, `created_at`。
|
||||
|
||||
### 2.13 check_ins
|
||||
|
||||
唯一 `(user_id, day)`,day=`YYYY-MM-DD`;`points`, `streak`。
|
||||
|
||||
### 2.14 lottery_draws
|
||||
|
||||
每日抽奖唯一 `(user_id, day)`;`points` 可为 0。
|
||||
|
||||
### 2.15 post_content_unlocks
|
||||
|
||||
唯一 `(user_id, post_id, block_key)`;`cost`。
|
||||
|
||||
### 2.16 site_pages
|
||||
|
||||
`title`, `slug`(unique), `content`, `published`, `sort_order`, `show_in_footer`, `show_in_nav` + soft delete。
|
||||
|
||||
### 2.17 polls / poll_options / poll_votes
|
||||
|
||||
- Poll:`post_id` unique;`multi`, `max_choices`, `closed`, `ends_at`
|
||||
- Option:`post_id`, `text`(64), `sort_order`, `vote_count`
|
||||
- Vote:唯一 `(post_id, option_id, user_id)`(多选时多行)
|
||||
|
||||
### 2.18 post_lottery_winners
|
||||
|
||||
`post_id`, `user_id`, `comment_id`, `created_at`。
|
||||
|
||||
### 2.19 badge_defs / user_badges
|
||||
|
||||
BadgeDef:`code` unique, `name`, `description`, `icon`, `kind`=`auto`\|`limited`, `metric`, `threshold`, `sort_order`, `enabled`。
|
||||
UserBadge:唯一 `(user_id, badge_id)`;`awarded_at`, `awarded_by`(0=系统)。
|
||||
|
||||
### 2.20 forum_settings
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| key | PK string(64) |
|
||||
| value | string(2048) |
|
||||
|
||||
### 2.21 oauth_clients / oauth_auth_codes
|
||||
|
||||
Client:`client_id` unique, `client_secret_hash`, `name`, `redirect_uris`(可多行), `enabled`。
|
||||
AuthCode:一次性码 + PKCE 字段 + `expires_at` + `used`。
|
||||
|
||||
### 2.22 gitea_repos
|
||||
|
||||
同步缓存:`gitea_id` unique, owner/name/full_name, description, html_url, language, stars/forks, private, updated_at_remote, forum_user_id, synced_at。
|
||||
|
||||
---
|
||||
|
||||
## 3. 枚举全集
|
||||
|
||||
### 3.1 角色 Role
|
||||
|
||||
`user` | `admin`
|
||||
|
||||
### 3.2 内容状态 ContentStatus
|
||||
|
||||
`pending` | `published` | `rejected`
|
||||
|
||||
### 3.3 帖类型 PostType
|
||||
|
||||
`normal` | `question` | `poll` | `bounty` | `lottery`
|
||||
|
||||
### 3.4 悬赏 BountyStatus
|
||||
|
||||
`open` | `awarded` | `refunded`(空串视为非悬赏)
|
||||
|
||||
### 3.5 帖内抽奖 PostLotteryStatus
|
||||
|
||||
`open` | `drawn`
|
||||
|
||||
### 3.6 私信 kind
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| user | 用户互发 |
|
||||
| system | 系统通知 |
|
||||
| reject | 帖/评被拒 |
|
||||
| report_result | 举报处理结果 |
|
||||
| reply | 被回复 |
|
||||
| mention | 被 @ |
|
||||
| moderation | 待审提醒管理员 |
|
||||
|
||||
### 3.7 举报
|
||||
|
||||
Status:`pending` | `resolved` | `dismissed`
|
||||
Reason:`spam` | `abuse` | `illegal` | `irrelevant` | `other`
|
||||
|
||||
### 3.8 友链申请
|
||||
|
||||
`pending` | `approved` | `rejected`
|
||||
|
||||
### 3.9 积分 reason
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| check_in | 签到 |
|
||||
| lottery | 每日抽奖 |
|
||||
| unlock_spend | 解锁消费 |
|
||||
| creator_income | 创作分成 |
|
||||
| admin_adjust | 管理员调账 |
|
||||
| bounty_escrow | 悬赏托管 |
|
||||
| bounty_award | 悬赏发放 |
|
||||
| bounty_refund | 悬赏退回 |
|
||||
|
||||
### 3.10 徽章
|
||||
|
||||
Kind:`auto` | `limited`
|
||||
Metric:`tenure_days` | `likes_received` | `creator_income`
|
||||
|
||||
---
|
||||
|
||||
## 4. 等级(Exp → Level)
|
||||
|
||||
源:[`model/level.go`](../../models/level.go)
|
||||
|
||||
| Level | 最低 Exp |
|
||||
|-------|----------|
|
||||
| 1 | 0 |
|
||||
| 2 | 20 |
|
||||
| 3 | 50 |
|
||||
| 4 | 100 |
|
||||
| 5 | 200 |
|
||||
| 6 | 400 |
|
||||
| 7 | 800 |
|
||||
| 8 | 1500 |
|
||||
| 9 | 3000 |
|
||||
| 10 | 5000 |
|
||||
|
||||
管理员设等级时,应把 Exp 调到该等级门槛(见后台 API)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 内置自动徽章(seed)
|
||||
|
||||
源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
|
||||
|
||||
| code | 名称 | metric | threshold |
|
||||
|------|------|--------|-----------|
|
||||
| tenure_30 | 初来乍到 | tenure_days | 30 |
|
||||
| tenure_365 | 资深居民 | tenure_days | 365 |
|
||||
| likes_10 | 小有人气 | likes_received | 10 |
|
||||
| likes_100 | 人气作者 | likes_received | 100 |
|
||||
| likes_1000 | 人气巨星 | likes_received | 1000 |
|
||||
| income_100 | 小有进账 | creator_income | 100 |
|
||||
| income_1000 | 创作达人 | creator_income | 1000 |
|
||||
|
||||
已存在同 `code` 则跳过插入。
|
||||
|
||||
---
|
||||
|
||||
## 6. forum_settings 键与默认值
|
||||
|
||||
源:[`service/settings.go`](../../services/settings.go)、[`service/permalink.go`](../../services/permalink.go)
|
||||
|
||||
### 6.1 论坛限制
|
||||
|
||||
| Key | 默认 | 说明 |
|
||||
|-----|------|------|
|
||||
| post_edit_window_hours | 24 | 0 可表示特殊策略,以实现为准 |
|
||||
| comment_edit_window_minutes | 3 | |
|
||||
| rate_limit_post | 10 | 窗口内次数 |
|
||||
| rate_limit_comment | 10 | |
|
||||
| rate_limit_register | 10 | |
|
||||
| rate_limit_login | 10 | |
|
||||
| rate_limit_window_sec | 60 | |
|
||||
| post_title_max | 128 | |
|
||||
| post_tags_max | 256 | |
|
||||
| post_content_max | 50000 | |
|
||||
| comment_max | 5000 | |
|
||||
| search_keyword_min | 1 | |
|
||||
| search_keyword_max | 50 | |
|
||||
| page_size_default | 30 | API 硬上限 100 |
|
||||
| password_min_len | 6 | |
|
||||
| avatar_max_mb | 2 | |
|
||||
| signature_max | 200 | |
|
||||
| open_posts_in_new_tab | 1 | |
|
||||
| open_content_links_in_new_tab | 1 | |
|
||||
|
||||
### 6.2 Feed / 侧栏 / 友链展示
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| feed_list_style | `title`(另有 `excerpt` / `thumbnail`) |
|
||||
| aside_show_tag_cloud | 0 |
|
||||
| aside_show_recent_comments | 0 |
|
||||
| aside_show_friend_links | 1 |
|
||||
| aside_widgets | JSON 数组,见下 |
|
||||
| nav_show_friend_links | 1 |
|
||||
| footer_show_friend_links | 1 |
|
||||
| friend_link_reciprocal_check | 0 |
|
||||
| permalink_enabled | 0 |
|
||||
| permalink_ext | `html` |
|
||||
|
||||
默认 `aside_widgets`:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id":"tag_cloud","enabled":false},
|
||||
{"id":"recent_comments","enabled":false},
|
||||
{"id":"friend_links","enabled":true}
|
||||
]
|
||||
```
|
||||
|
||||
合法 widget id:`tag_cloud` | `recent_comments` | `recent_users` | `friend_links`。
|
||||
|
||||
### 6.3 SMTP
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| smtp_enabled | 0 |
|
||||
| smtp_host | |
|
||||
| smtp_port | 465 |
|
||||
| smtp_username / smtp_password | |
|
||||
| smtp_from | |
|
||||
| smtp_from_name | 姜十三论坛 |
|
||||
| smtp_encryption | `ssl`(另有 `none` / `starttls`) |
|
||||
|
||||
### 6.4 OIDC
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| oidc_enabled | 0 |
|
||||
| oidc_root_url | |
|
||||
| oidc_group_claim | groups |
|
||||
| oidc_admin_group | gitea-admin |
|
||||
| oidc_user_group | gitea-users |
|
||||
| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
|
||||
|
||||
### 6.4b 敏感词
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
|
||||
|
||||
### 6.5 Gitea 同步(**后置**,键保留兼容)
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| gitea_sync_enabled | 0 |
|
||||
| gitea_base_url | |
|
||||
| gitea_token | |
|
||||
| gitea_sync_interval_min | 60 |
|
||||
|
||||
本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
|
||||
|
||||
### 6.6 存储
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| storage_type | local |
|
||||
| storage_endpoint / region / bucket | region 默认 us-east-1 |
|
||||
| storage_access_key / storage_secret_key | |
|
||||
| storage_public_base_url / storage_prefix | |
|
||||
| storage_force_path_style | 1 |
|
||||
| storage_image_delivery | webp(或 original) |
|
||||
|
||||
### 6.7 站点品牌
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| site_name | 姜十三论坛 |
|
||||
| site_slogan | 拾三一隅,自在交流 |
|
||||
| site_description / site_keywords | 空 |
|
||||
| site_logo_mark | 姜 |
|
||||
| site_logo / site_favicon / site_og_image | 空 |
|
||||
| site_icp_beian | 空 |
|
||||
| site_icp_beian_url | https://beian.miit.gov.cn/ |
|
||||
| site_friend_links | `[]` JSON,最多 20 条 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 升级兼容补丁(现网 InitDB)
|
||||
|
||||
[`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
|
||||
|
||||
- 空 `status` 的帖/评 → `published`
|
||||
- 空 `post_type` → `normal`
|
||||
- Exp=0 用户按存量内容粗算经验:`posts*10 + comments*2 + like_sum`
|
||||
|
||||
新站若从空库开始可忽略;若迁移旧库需保留等价 backfill。
|
||||
|
||||
---
|
||||
|
||||
## 8. 内容门控在库中的形态
|
||||
|
||||
**无独立表**存放门控块;存在 `posts.content` HTML 中,例如:
|
||||
|
||||
```html
|
||||
<members-only>...</members-only>
|
||||
<reply-only>...</reply-only>
|
||||
<points-only data-cost="10">...</points-only>
|
||||
```
|
||||
|
||||
积分解锁 `block_key` = `sha256(innerHTML)[:16]`(hex),见 [`service/unlock.go`](../../services/unlock.go)。
|
||||
391
docs/rebuild-spec/04-api.md
Normal file
391
docs/rebuild-spec/04-api.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# 04 · HTTP API 合约
|
||||
|
||||
> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
|
||||
> **前置**:[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
||||
> **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
|
||||
|
||||
本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
|
||||
**当前进程仅注册** health / OIDC / robots / sitemap / media thumb 等机器相关路由;论坛 CRUD 的 `/api/*` **handler 已从本分支删除**(对照实现见 `main`,合约形状见下文历史章节)。
|
||||
|
||||
`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本分支已注册的机器入口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 探活 |
|
||||
| GET | `/robots.txt` | 抓取规则 |
|
||||
| GET | `/sitemap.xml` | 站点地图 |
|
||||
| GET/POST | `/oauth/*`、`/.well-known/openid-configuration` | OIDC Provider |
|
||||
| GET | `/media/thumb/*`、`/uploads/*` | 媒体 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 历史 JSON 合约(main / 对照,本分支默认不挂载)
|
||||
|
||||
以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
|
||||
|
||||
### 通用约定(历史)
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| Base | 同源;`credentials: 'same-origin'` |
|
||||
| 成功 | HTTP 2xx + JSON body |
|
||||
| 失败 | 非 2xx + `{ "error": "..." }` |
|
||||
| 鉴权 | Cookie `jiang13_session`(opaque);机器 OIDC 用 Bearer |
|
||||
|
||||
### 分页形态差异
|
||||
|
||||
| 场景 | 典型字段 |
|
||||
|------|----------|
|
||||
| 前台帖列表 | `posts`, `total`, `page`, `size`, `has_more` |
|
||||
| 后台多数列表 | `total`, `page`, `total_pages` + 实体数组 |
|
||||
| 私信会话消息 | `before` 游标式 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 基础设施 / SEO / 静态(节选,仍有效)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/health` | 无 | `{ "status": "ok" }` |
|
||||
| GET | `/robots.txt` | 无 | 文本 |
|
||||
| GET | `/sitemap.xml` | 无 | XML |
|
||||
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
|
||||
| GET | `/uploads/*` | 无 | 静态上传文件 |
|
||||
|
||||
---
|
||||
|
||||
## 3. OIDC Provider
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/.well-known/openid-configuration` | 无 | Discovery |
|
||||
| GET | `/oauth/jwks` | 无 | JWKS |
|
||||
| GET | `/oauth/authorize` | OptionalAuth | 授权码流程 |
|
||||
| POST | `/oauth/token` | 无(客户端凭证) | 换 token |
|
||||
| GET/POST | `/oauth/userinfo` | Bearer | 用户信息 |
|
||||
| GET/POST | `/oauth/logout` | 视实现 | 登出 |
|
||||
|
||||
细节以 [`service/oidc.go`](../../services/oidc.go) / [`handler/oidc.go`](../../routers/api/oidc.go) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 4. 公开 API(`/api` + OptionalAuth)
|
||||
|
||||
### 4.1 会话与站点
|
||||
|
||||
| 方法 | 路径 | 响应要点 |
|
||||
|------|------|----------|
|
||||
| GET | `/api/me` | `{ user: UserSelf \| null }` |
|
||||
| GET | `/api/stats` | `{ users, posts, boards, comments }` |
|
||||
| GET | `/api/forum-limits` | `ForumLimitsPublic`(无限流内部字段) |
|
||||
| GET | `/api/site-branding` | `SiteBranding`(可含 `site_url`) |
|
||||
| GET | `/api/captcha` | `{ id, image }` image 为 data URL 或 base64 |
|
||||
| GET | `/api/register/config` | 见下 |
|
||||
|
||||
**RegisterConfig**
|
||||
|
||||
```json
|
||||
{
|
||||
"is_first_user": true,
|
||||
"mail_ready": false,
|
||||
"require_email_code": false,
|
||||
"register_open": true,
|
||||
"email_code_len": 6
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 认证(限流)
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/register` | Form/JSON: username, password, nickname, email, captcha_id, captcha, email_code? | 成功后通常种 cookie |
|
||||
| POST | `/api/login` | Form: username, password | 种 cookie |
|
||||
| POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` |
|
||||
| POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` |
|
||||
| POST | `/api/password-reset` | JSON `{ email, email_code, new_password }` | `{ message }` |
|
||||
|
||||
### 4.3 内容只读
|
||||
|
||||
| 方法 | 路径 | Query / 说明 |
|
||||
|------|------|----------------|
|
||||
| GET | `/api/boards` | `{ boards: Board[] }` |
|
||||
| GET | `/api/posts` | 见下表 |
|
||||
| GET | `/api/posts/hot` | 热门列表 |
|
||||
| GET | `/api/posts/:id` | `skip_view=1` 可选;返回 `PostDetailResponse` |
|
||||
| GET | `/api/posts/:id/comments` | `my_ids` 可选(逗号分隔,便于标自己的楼) |
|
||||
| GET | `/api/tags` | `limit` 默认 40 → `{ tags: [{name,count}] }` |
|
||||
| GET | `/api/comments/recent` | `{ comments: RecentComment[] }` |
|
||||
| GET | `/api/users/search` | `q`, `limit` |
|
||||
| GET | `/api/users/recent` | `{ users: RecentUser[] }` |
|
||||
| GET | `/api/users/:id` | `{ user: UserPublic, stats }` |
|
||||
| GET | `/api/pages` | 已发布摘要列表 |
|
||||
| GET | `/api/pages/:slug` | 单页详情 |
|
||||
| GET | `/api/projects` | `page`, `limit`, `q` |
|
||||
|
||||
**GET `/api/posts` Query**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| page | 默认 1 |
|
||||
| size | 默认 page_size_default,上限 100 |
|
||||
| board_id | 0 或不传=全部 |
|
||||
| user_id | 某用户的帖 |
|
||||
| keyword | 搜索词 |
|
||||
| tag | 标签 |
|
||||
| author | 用户名优先,否则昵称精确匹配 |
|
||||
| title_only | `1`/`true` 仅搜标题 |
|
||||
| sort | `latest` \| `reply` \| `hot` |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"posts": [ /* PostItem */ ],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"size": 30,
|
||||
"has_more": true
|
||||
}
|
||||
```
|
||||
|
||||
**PostDetailResponse 要点**
|
||||
|
||||
```json
|
||||
{
|
||||
"post": { /* PostItem + content */ },
|
||||
"comment_count": 0,
|
||||
"liked": false,
|
||||
"favorited": false,
|
||||
"has_replied": false,
|
||||
"can_edit": true,
|
||||
"edit_block_reason": "",
|
||||
"is_edited": false,
|
||||
"post_edit_window_hours": 24,
|
||||
"poll": { /* PollView 可选 */ },
|
||||
"lottery": { /* PostLotteryView 可选 */ },
|
||||
"bounty_can_refund": false,
|
||||
"bounty_refund_block_reason": "",
|
||||
"bounty_eligible_reply_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 游客可写评论
|
||||
|
||||
| 方法 | 路径 | 限流 | Body |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/posts/:id/comments` | comment | Form: content, reply_to?, is_private?, 以及游客字段(以实现为准) |
|
||||
|
||||
登录用户发评也走此路径(RequireAuth 组外公开组已注册该路由)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 需登录 API(`/api` + RequireAuth)
|
||||
|
||||
### 5.1 会话与资料
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/logout` | | 清 cookie |
|
||||
| GET | `/api/favorites` | | `{ favorites, total }` |
|
||||
| GET | `/api/profile/stats` | | `{ stats: UserActivityStats }` |
|
||||
| POST | `/api/profile/nickname` | Form nickname | |
|
||||
| POST | `/api/profile/signature` | Form signature | `{ message, user }` |
|
||||
| POST | `/api/profile/password` | Form old_password, new_password | |
|
||||
| POST | `/api/profile/avatar` | Form avatar=file | `{ avatar }` |
|
||||
| POST | `/api/uploads/image` | Form image=file | `{ url }` |
|
||||
|
||||
### 5.2 帖子写操作
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/posts` | Form: board_id, title, content, tags?, post_type?, poll_options?, bounty_points?, lottery_winner_count? | `{ message, post_id, status }` |
|
||||
| PUT | `/api/posts/:id` | Form: title, content, tags?, board_id?, post_type? | `{ message }` |
|
||||
| DELETE | `/api/posts/:id` | | 软删 |
|
||||
| GET | `/api/posts/:id/revisions` | | `{ revisions }` |
|
||||
| GET | `/api/posts/:id/revisions/:revId` | | `{ revision }` |
|
||||
| POST | `/api/posts/:id/like` | | `{ liked, like_count }` |
|
||||
| POST | `/api/posts/:id/favorite` | | `{ favorited }` |
|
||||
| POST | `/api/posts/:id/resolve` | Form resolved=`1`\|`0` | `{ question_resolved }` |
|
||||
| POST | `/api/posts/:id/poll/vote` | JSON `{ option_ids: number[] }` | `{ poll }` |
|
||||
| POST | `/api/posts/:id/poll/close` | | `{ poll }` |
|
||||
| POST | `/api/posts/:id/bounty/award` | Form comment_id | |
|
||||
| POST | `/api/posts/:id/bounty/refund` | | |
|
||||
| POST | `/api/posts/:id/lottery/draw` | | `{ lottery }` |
|
||||
| POST | `/api/posts/:id/report` | JSON `{ reason, detail? }` | `{ report }` |
|
||||
| POST | `/api/posts/:id/unlock` | JSON `{ block_key }` | 见下 |
|
||||
|
||||
**poll_options JSON 示例**(Form 字段字符串)
|
||||
|
||||
```json
|
||||
{
|
||||
"multi": false,
|
||||
"max_choices": 1,
|
||||
"ends_at": "2026-09-01T12:00:00Z",
|
||||
"options": [{ "text": "选项A" }, { "text": "选项B" }]
|
||||
}
|
||||
```
|
||||
|
||||
**unlock 响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "...",
|
||||
"unlock": {
|
||||
"block_key": "abcdef0123456789",
|
||||
"cost": 10,
|
||||
"points_balance": 90,
|
||||
"inner_html": "<p>...</p>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 评论写操作
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| POST | `/api/comments/:id/like` | → `{ liked, like_count }` |
|
||||
| POST | `/api/comments/:id/report` | JSON `{ reason, detail? }` |
|
||||
| PUT | `/api/comments/:id` | Form content |
|
||||
| DELETE | `/api/comments/:id` | |
|
||||
|
||||
### 5.4 私信
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/messages/unread-count` | `{ count, dm_count?, notify_count? }` |
|
||||
| GET | `/api/messages/notifications` | page, size, kind |
|
||||
| POST | `/api/messages/notifications/read` | |
|
||||
| GET | `/api/messages/conversations` | page, size |
|
||||
| GET | `/api/messages/conversations/:peerId` | size, before;peerId=0 为系统 |
|
||||
| POST | `/api/messages/conversations/:peerId/read` | |
|
||||
| POST | `/api/messages` | JSON `{ to_user_id, subject?, content }` |
|
||||
| POST | `/api/messages/read-all` | |
|
||||
|
||||
### 5.5 经济
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/me/points` | page;含 ledger、check_in、lottery |
|
||||
| GET/POST | `/api/me/check-in` | 状态 / 执行签到 |
|
||||
| GET/POST | `/api/me/lottery` | 状态 / 抽奖 |
|
||||
|
||||
### 5.6 友链申请
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| POST | `/api/friend-links/apply` | JSON name, url, logo, link_on_homepage, reciprocal_page_url? |
|
||||
| POST | `/api/friend-links/logo` | Form logo=file → `{ url }` |
|
||||
| GET | `/api/friend-links/my-applies` | |
|
||||
| PUT | `/api/friend-links/applies/:id` | 同申请字段 |
|
||||
| DELETE | `/api/friend-links/applies/:id` | 取消 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 管理 API(`/api/admin` + Auth + Admin)
|
||||
|
||||
### 6.1 仪表盘与设置
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/dashboard` | AdminDashboard |
|
||||
| GET | `/settings` | AdminSettings 聚合 |
|
||||
| PUT | `/settings/forum` | ForumLimits |
|
||||
| PUT | `/settings/mail` | MailConfig |
|
||||
| POST | `/settings/mail/test` | `{ to }` |
|
||||
| PUT | `/settings/oidc` | OIDCConfig |
|
||||
| PUT | `/settings/gitea` | **后置**(501) |
|
||||
| POST | `/settings/gitea/sync` | **后置**(501) |
|
||||
| PUT | `/settings/storage` | StorageConfig |
|
||||
| PUT | `/settings/branding` | SiteBranding |
|
||||
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
|
||||
| POST | `/settings/branding/clear` | JSON `{ kind }` |
|
||||
| GET/PUT | `/settings/filter-words` | GET 读;PUT `{ content }` |
|
||||
|
||||
(上表路径均相对于 `/api/admin`。)
|
||||
|
||||
### 6.2 OAuth 客户端
|
||||
|
||||
| 方法 | 路径 |
|
||||
|------|------|
|
||||
| GET/POST | `/oauth/clients` |
|
||||
| PUT/DELETE | `/oauth/clients/:id` |
|
||||
|
||||
创建/更新 body:`name`, `redirect_uris`, `client_id?`, `enabled?`, `client_secret?`, `rotate_secret?`。
|
||||
|
||||
### 6.3 板块 / 单页 / 友链
|
||||
|
||||
| 方法 | 路径 |
|
||||
|------|------|
|
||||
| POST/PUT/DELETE | `/boards`, `/boards/:id` |
|
||||
| GET/POST | `/pages` |
|
||||
| GET/PUT/DELETE | `/pages/:id` |
|
||||
| PUT | `/pages/:id/published` → `{ published }` |
|
||||
| GET | `/friend-link-applies` |
|
||||
| PUT | `/friend-link-settings` |
|
||||
| POST | `/friend-link-applies/:id/approve` \| `reject` \| `recheck` |
|
||||
|
||||
### 6.4 帖子审核与运营
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| GET | `/posts` | page, keyword, status |
|
||||
| GET | `/posts/trash` | |
|
||||
| POST | `/posts/:id/pin` | `{ pinned }` |
|
||||
| POST | `/posts/:id/board-pin` | `{ board_pinned }` |
|
||||
| POST | `/posts/:id/feature` | `{ featured }` |
|
||||
| POST | `/posts/:id/lock` | `{ locked }` → edit_locked |
|
||||
| POST | `/posts/:id/comments-lock` | `{ locked }` |
|
||||
| POST | `/posts/:id/approve` | |
|
||||
| POST | `/posts/:id/reject` | `{ reason }` |
|
||||
| POST | `/posts/:id/restore` | |
|
||||
| DELETE | `/posts/:id/purge` | 硬删 |
|
||||
| DELETE | `/posts/:id` | 软删 |
|
||||
|
||||
### 6.5 评论 / 举报 / 用户 / 徽章 / 媒体 / 备份
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/comments`, `/comments/trash` | |
|
||||
| GET | `/comments/:id/revisions` | |
|
||||
| POST | `/comments/:id/approve` \| `reject` \| `restore` | reject 可带 reason |
|
||||
| DELETE | `/comments/:id`, `/comments/:id/purge` | |
|
||||
| GET | `/reports` | page, status |
|
||||
| POST | `/reports/:id/handle` | `{ action, handle_note?, reject_reason? }`;action=`dismiss`\|`resolve`\|`reject_post`\|`reject_comment` |
|
||||
| GET | `/users` | page, keyword, filter |
|
||||
| POST | `/users/:id/ban` | `{ banned }` |
|
||||
| POST | `/users/:id/verify` | `{ verified }` |
|
||||
| POST | `/users/:id/level` | `{ level }` |
|
||||
| POST | `/users/:id/points` | `{ delta, note? }` |
|
||||
| POST | `/users/:id/badges` | `{ badge_id, revoke? }` |
|
||||
| GET/POST | `/badges` | 列表 / upsert |
|
||||
| GET | `/media` | category, page, size, q |
|
||||
| POST | `/media/delete` | `{ urls: string[] }` |
|
||||
| POST | `/backup` | `{ filename, download }` |
|
||||
| GET | `/backup/download/:name` | 文件下载 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心类型速查(与前端对齐)
|
||||
|
||||
详见 [`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)。实现时至少对齐:
|
||||
|
||||
- `User` / `UserPublic` / `UserActivityStats`
|
||||
- `Board` / `PostItem` / `PostDetailResponse` / `Comment`
|
||||
- `ForumLimits` / `ForumLimitsPublic` / `SiteBranding`
|
||||
- `PollView` / `PostLotteryView`
|
||||
- `PrivateMessage` / `MessageConversation`
|
||||
- `PostReport` / `FriendLinkApply` / `BadgeDef` / `PointLedger`
|
||||
- `CheckInStatus` / `LotteryStatus`
|
||||
- `AdminDashboard` / `AdminSettings` / `StorageConfig` / `MailConfig` / `OIDCConfig`
|
||||
|
||||
---
|
||||
|
||||
## 8. 鉴权错误语义(现网)
|
||||
|
||||
中间件对未登录 / 过期 / 禁言返回 JSON error(并可能清 cookie)。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。
|
||||
|
||||
源:[`middleware/auth.go`](../../modules/auth/auth.go)。
|
||||
230
docs/rebuild-spec/05-business-rules.md
Normal file
230
docs/rebuild-spec/05-business-rules.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 05 · 业务规则与状态机
|
||||
|
||||
> **读者**:实现领域逻辑的 AI(最易「看起来像但算错」)
|
||||
> **前置**:[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md)
|
||||
> **源码**:[`service/`](../../services/)、[`model/models.go`](../../models/models.go)
|
||||
|
||||
---
|
||||
|
||||
## 1. 注册与引导
|
||||
|
||||
源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
|
||||
| 开放注册 | 安装完成后开放;不依赖 SMTP |
|
||||
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
|
||||
| 会话 | Cookie `jiang13_session` = opaque id;表 `sessions`;HttpOnly + SameSite=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
|
||||
| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
|
||||
| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
|
||||
|
||||
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内容审核
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: 普通用户发帖或评论
|
||||
[*] --> published: admin或verified免审
|
||||
pending --> published: 管理员通过
|
||||
pending --> rejected: 管理员拒绝
|
||||
published --> pending: 非免审用户编辑后可再进审
|
||||
```
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 免审 | `role=admin` 或 `verified=true`(`SkipsModeration`) |
|
||||
| 可见性 | `pending`/`rejected`:**仅作者与管理员**可见(对外表现为 404) |
|
||||
| 列表 | 公开 Feed 只出 `published` |
|
||||
| 拒绝 | 可写原因;通知作者(站内信 kind=`reject`,可选邮件) |
|
||||
| 待审提醒 | 通知管理员(kind=`moderation`) |
|
||||
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
|
||||
|
||||
源:[`service/post.go`](../../services/post.go) `CanViewPost`、[`service/comment.go`](../../services/comment.go)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 编辑时限与锁
|
||||
|
||||
| 对象 | 规则 |
|
||||
|------|------|
|
||||
| 帖子 | 普通用户在 `post_edit_window_hours`(默认 24)内可编;超时不可(管理员除外) |
|
||||
| 帖子 | `edit_locked=true` 时非管理员不可编 |
|
||||
| 评论 | `comment_edit_window_minutes`(默认 3) |
|
||||
| 评论 | 帖子 `comments_locked` 时禁止新评论 |
|
||||
|
||||
详情接口返回 `can_edit`、`edit_block_reason`、`post_edit_window_hours`。
|
||||
|
||||
修订:每次成功修改前写入 `post_revisions` / `comment_revisions`(旧内容快照)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 经验 Exp(不可消费)
|
||||
|
||||
| 事件 | Delta |
|
||||
|------|------|
|
||||
| 发帖成功(公开路径) | +10 |
|
||||
| 评论成功 | +2 |
|
||||
| 帖子被点赞 | +1(作者) |
|
||||
|
||||
源:[`service/post.go`](../../services/post.go)、[`service/comment.go`](../../services/comment.go)、[`service/badge.go`](../../services/badge.go) `AddExp`。
|
||||
|
||||
等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 内容门控(红action)
|
||||
|
||||
源:[`service/content.go`](../../services/content.go)、[`handler/api.go`](../../routers/api/api.go) `APIPostDetail`、[`service/unlock.go`](../../services/unlock.go)
|
||||
|
||||
### 5.1 出口顺序(详情)
|
||||
|
||||
1. `SanitizePostHTML`(消毒)
|
||||
2. 若 **游客**:`RedactMembersOnlyHTML` + `RedactReplyOnlyHTML`
|
||||
3. 若 **已登录** 且非管理员、非作者、且未回复:仅 `RedactReplyOnlyHTML`
|
||||
4. 作者与管理员:members/reply 块不遮
|
||||
5. 积分块:按已解锁 key 集合 `RedactPointsOnlyHTML`;作者/管理员策略以实现为准(作者可免费解锁记录)
|
||||
|
||||
搜索 / SEO:`RedactGatedPostHTML` = members + reply + points 全遮。
|
||||
|
||||
### 5.2 积分解锁
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| block_key | `hex(sha256(innerHTML))[:16]` |
|
||||
| cost | `data-cost`,最小 1 |
|
||||
| 读者 | 扣 `cost`(reason=`unlock_spend`) |
|
||||
| 作者分成 | `cost * 70 / 100`(`CreatorSharePercent`),reason=`creator_income`;并累加 `creator_income_total` |
|
||||
| 平台留存 | 剩余 30%(无单独流水,表现为读者扣全额、作者只加 70%) |
|
||||
| 作者自己 | cost=0 记解锁,无分成 |
|
||||
| 已解锁 | 返回错误「已解锁」 |
|
||||
| 防刷 | 双方账号注册未满 **7 天** 且 **LastLoginIP 相同** → 拒绝整单 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 特殊帖类型
|
||||
|
||||
### 6.1 问答 question
|
||||
|
||||
- `question_resolved` 布尔;作者(或管理员)可切换
|
||||
- 列表/详情用图标展示已解决状态
|
||||
|
||||
### 6.2 投票 poll
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 选项数 | 2–10;单选项 ≤64 字 |
|
||||
| 多选 | `multi`;`max_choices` 钳制在 1..选项数 |
|
||||
| 截止 | `ends_at` 可选;过期或 `closed` 不可再投 |
|
||||
| 投票 | 每用户每帖;已投不可改(`ErrPollAlreadyVoted`) |
|
||||
| 结束 | 作者或管理员 `poll/close` |
|
||||
|
||||
### 6.3 悬赏 bounty
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> open: 发帖托管积分
|
||||
open --> awarded: 采纳他人已发布评论
|
||||
open --> refunded: 退款
|
||||
```
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 发帖 | 积分 ≥1;立即 `bounty_escrow` 扣作者积分 |
|
||||
| 采纳 | 不能采纳自己的回复;评论须 published;全额给评论作者 `bounty_award` |
|
||||
| 退款 | 状态 open;作者在**无他人已发布回复**时可退;**管理员始终可强制退** |
|
||||
| 退款后 | status=`refunded`,`bounty_points=0`,积分退回作者 |
|
||||
|
||||
### 6.4 抽奖帖 lottery
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 中奖人数 | 1–20 |
|
||||
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
|
||||
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `post_lottery_winners`;status=`drawn` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 签到与每日抽奖
|
||||
|
||||
源:[`service/points.go`](../../services/points.go)
|
||||
|
||||
### 签到
|
||||
|
||||
- 自然日 `YYYY-MM-DD`(服务器本地时区)每用户一次
|
||||
- 连续:若昨日报到则 streak+1,否则 1
|
||||
- 奖励:`5 + (streak-1)`,封顶 **15**,保底 5
|
||||
- 写 `check_ins` + `point_ledgers` reason=`check_in`
|
||||
|
||||
### 每日抽奖
|
||||
|
||||
- 每天一次;`cost=0`
|
||||
- 奖池权重:0×40, 2×30, 5×18, 10×10, 20×2
|
||||
- 中奖积分入账 reason=`lottery`
|
||||
|
||||
---
|
||||
|
||||
## 8. 评论特殊规则
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 楼层 | 按帖递增 |
|
||||
| 私密评论 | 仅作者、帖作者、管理员、以及相关可见链可见(见 `canViewPrivate`) |
|
||||
| 嵌套 | `thread_parent_id` 在父不可见时回挂祖先 |
|
||||
| @提及 | 解析后发 kind=`mention` |
|
||||
| 回复提醒 | kind=`reply`;可选 SMTP |
|
||||
| HasUserReplied | 已发布或审核中的评论算「已回复」(不含被拒),用于 reply-only |
|
||||
|
||||
---
|
||||
|
||||
## 9. 举报处理
|
||||
|
||||
管理员 `handle` action:
|
||||
|
||||
| action | 效果 |
|
||||
|--------|------|
|
||||
| dismiss | 驳回举报 |
|
||||
| resolve | 标记已处理(不必然删内容) |
|
||||
| reject_post | 拒绝/下架帖 |
|
||||
| reject_comment | 拒绝评论 |
|
||||
|
||||
结果通知举报人(kind=`report_result`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 友链
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 申请 | 登录用户;可上传 logo |
|
||||
| 回链检测 | 设置开启时抓取 reciprocal 页检查是否含本站链接 |
|
||||
| 通过 | 可写入品牌 `site_friend_links`(视实现:首页展示链接) |
|
||||
| 展示开关 | nav / footer / aside 独立 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 敏感词与限流
|
||||
|
||||
- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
|
||||
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
|
||||
|
||||
---
|
||||
|
||||
## 12. 徽章自动授予
|
||||
|
||||
定期或触发时检查 `BadgeDef`(kind=auto):tenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
|
||||
|
||||
---
|
||||
|
||||
## 13. 置顶排序语义
|
||||
|
||||
| 标记 | 首页全部 Feed | 板块 Feed |
|
||||
|------|---------------|-----------|
|
||||
| `pinned` | 抬升 | 抬升 |
|
||||
| `board_pinned` | **不**抬升 | 抬升 |
|
||||
| `featured` | 标记展示,不一定改变排序 | 同左 |
|
||||
|
||||
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。
|
||||
229
docs/rebuild-spec/06-pages-ux.md
Normal file
229
docs/rebuild-spec/06-pages-ux.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# 06 · 页面、交互与信息架构
|
||||
|
||||
> **读者**:实现前台 / 后台 UI 的 AI
|
||||
> **前置**:[02-features.md](02-features.md)
|
||||
> **源码**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`frontend/src/pages/`]((仅 main)frontend/src/pages/)、[`frontend/src/components/`]((仅 main)frontend/src/components/)、[`frontend/src/layouts/`]((仅 main)frontend/src/layouts/)
|
||||
|
||||
视觉可重设(见 [10-design-system.md](10-design-system.md));**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
|
||||
|
||||
---
|
||||
|
||||
## 1. 路由表
|
||||
|
||||
> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
|
||||
|
||||
### 1.1 认证
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/login` | 登录 / 登出 | 已迁 |
|
||||
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
|
||||
| `/forgot-password` | 忘记密码 | 已迁 |
|
||||
|
||||
### 1.2 前台
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/` | Feed | 已迁 |
|
||||
| `/board/:id` | 板块 Feed | 已迁 |
|
||||
| `/boards` | 板块索引 | 已迁 |
|
||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
||||
| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
|
||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||
| `/user/:id` | 公开用户页 | 已迁 |
|
||||
| `/favorites` | 收藏 | 已迁 |
|
||||
| `/projects` | Gitea 码桶 | 后置 |
|
||||
| `/links` | 友链 | 已迁 |
|
||||
| `/messages` | 私信/通知会话列表 | 已迁 |
|
||||
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
|
||||
| `/page/:slug` | 站点单页 | 已迁 |
|
||||
| `*` | 404 / pending | 已迁 |
|
||||
|
||||
### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/admin` | 重定向 dashboard | 已迁 |
|
||||
| `/admin/dashboard` | 概览计数 | 已迁 |
|
||||
| `/admin/boards` | 板块 CRUD | 已迁 |
|
||||
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
||||
| `/admin/settings` | 品牌(含 Logo/Favicon/OG)+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
|
||||
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
|
||||
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
|
||||
| `/admin/reports` | 举报处理 | 已迁 |
|
||||
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
|
||||
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
|
||||
| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 |
|
||||
| `/admin/trash` | 帖回收站 | 已迁 |
|
||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
||||
|
||||
未迁(原 SPA):OIDC·Gitea·存储热切换 Admin 等。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三栏布局(桌面)
|
||||
|
||||
```text
|
||||
+------------------+---------------------------+------------------+
|
||||
| Sidebar | Feed / 主内容 | RightPanel |
|
||||
| - 全部/收藏/码桶 | - FeedHeader / SortBar | - 签到条(登录) |
|
||||
| - 板块列表 | - VirtualPostList | - 热门帖 |
|
||||
| - 站点页/友链 | - 或 PostDetail 等 | - aside_widgets |
|
||||
| - 管理入口 | | 标签云/评论/ |
|
||||
| | | 用户/友链 |
|
||||
+------------------+---------------------------+------------------+
|
||||
| Footer: ICP / 友链入口 / 站点页链接 |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2.1 左栏 Sidebar
|
||||
|
||||
- 全部帖子、我的收藏(登录)、开源码桶
|
||||
- 板块列表(图标 + 色点 + 名称);空站引导「创建第一个板块」
|
||||
- 站点区:友链入口(`nav_show_friend_links`)、`show_in_nav` 的站点页
|
||||
- 管理员:管理后台入口
|
||||
|
||||
### 2.2 中栏
|
||||
|
||||
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。
|
||||
|
||||
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 待审/被拒横幅(作者/管理员)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
|
||||
|
||||
### 2.3 右栏 RightPanel
|
||||
|
||||
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
|
||||
- 热门帖 — **已迁** SSR(固定块)
|
||||
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON
|
||||
- 桌面三列;≤900px 隐藏右栏
|
||||
|
||||
### 2.4 移动端
|
||||
|
||||
- 侧栏抽屉化;顶栏搜索/发帖/登录触手可及
|
||||
- `PullToRefresh` 下拉刷新
|
||||
- 触控友好列表行高
|
||||
|
||||
### 2.5 主题
|
||||
|
||||
- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
|
||||
- 显式浅/深时设 `<html data-theme="light|dark">`;`system` 时去掉该属性,交给媒体查询
|
||||
- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值
|
||||
|
||||
---
|
||||
|
||||
## 3. 发帖页 Compose
|
||||
|
||||
组件:`ComposeHeader`、`ComposeContextBar`(帖类型)、`ComposeSpecialFields`、`ComposeDocument` / `ArticleEditor`。
|
||||
|
||||
### 3.1 帖类型切换
|
||||
|
||||
| 类型 | 附加 UI |
|
||||
|------|---------|
|
||||
| 讨论 | 无 |
|
||||
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
|
||||
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
|
||||
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
||||
| 抽奖 | 中奖人数 1–20 — SSR compose |
|
||||
|
||||
### 3.2 编辑器能力(本分支:Markdown 渐进增强)
|
||||
|
||||
源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**)
|
||||
|
||||
| 能力 | SSR 状态 |
|
||||
|------|----------|
|
||||
| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
|
||||
| 粗体/斜体/删除线/行内代码 | 已迁 |
|
||||
| 链接 | prompt 插入 |
|
||||
| 围栏代码块 | 已迁(语言 class) |
|
||||
| 列表 / 引用 | 已迁 |
|
||||
| 图片上传 | `/compose/upload` |
|
||||
| 登录/回复/积分可见门控 | compose 工具栏 |
|
||||
| 预览 | `POST /compose/preview`(同消毒管线) |
|
||||
| Tab 缩进 / 未保存离开 | `beforeunload` |
|
||||
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
|
||||
|
||||
共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 帖子详情关键交互
|
||||
|
||||
| 模块 | 行为 |
|
||||
|------|------|
|
||||
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
||||
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
|
||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
|
||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
|
||||
| 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR |
|
||||
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
||||
| 图片 | Lightbox 查看 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 消息页
|
||||
|
||||
- 左:会话列表(系统会话单独)— SSR `/messages`;快捷链到回复/提及筛选
|
||||
- 右:消息时间线;发送框 — SSR `/messages/with/:peerId`
|
||||
- 顶:未读角标(全局导航也可显示)
|
||||
- 通知筛选(kind)— 系统会话 `?kind=reply|mention|…`
|
||||
|
||||
---
|
||||
|
||||
## 6. 个人中心 / 公开主页
|
||||
|
||||
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
|
||||
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
|
||||
- 收藏列表 — SSR `/favorites`
|
||||
- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG)— SSR `/profile`;导航展示当前积分
|
||||
- 徽章展示:用户主页 / 个人中心 — SSR(访问时 `EvaluateAuto`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 友链页
|
||||
|
||||
- 展示品牌友链 — SSR `/links`
|
||||
- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply`
|
||||
- 我的申请状态列表;待审可取消
|
||||
- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关
|
||||
|
||||
---
|
||||
|
||||
## 8. 管理后台操作流(按页)
|
||||
|
||||
| 页 | 关键操作 |
|
||||
|----|----------|
|
||||
| Dashboard | 看计数与待办;点进对应列表 |
|
||||
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
|
||||
| Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` |
|
||||
| Links | 品牌友链增删;申请通过/拒绝/回链复检;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(aside 与 Settings 侧栏组件共用 `aside_widgets`) |
|
||||
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
|
||||
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
|
||||
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
|
||||
| Users | 搜索;禁言;认证;调积分;设等级 — SSR `/admin/users`(授徽章在 `/admin/badges`) |
|
||||
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
|
||||
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
|
||||
| Media | 分类浏览;批量删 |
|
||||
| Settings | 品牌(含 Logo/Favicon/OG)、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`(OIDC/Gitea/存储热切换未迁) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 全局 UX 细节
|
||||
|
||||
- Toast(sonner)反馈成功/失败
|
||||
- 路由级 ErrorBoundary / AppRouteError
|
||||
- 懒加载页面 + retry(`lazyWithRetry`)
|
||||
- 新标签打开帖子 / 正文外链:受 `open_posts_in_new_tab`、`open_content_links_in_new_tab` 控制
|
||||
- 文档标题:`站点名 - 标语`;详情页应换成帖标题(SSR 时首屏即正确)
|
||||
|
||||
---
|
||||
|
||||
## 10. SSR 重构提示(交互层)
|
||||
|
||||
当前 SPA 在客户端挂载后才拉 `/api/posts/:id`。新站应:
|
||||
|
||||
1. 服务端渲染列表项与帖文 HTML(已按门控红action)
|
||||
2. 水合后接上赞/评/解锁等交互
|
||||
3. 管理后台仍可为 CSR,但前台公开页优先 SSR
|
||||
|
||||
勿再维护「爬虫一套 HTML、用户一套空壳」双轨,除非过渡期兼容。
|
||||
213
docs/rebuild-spec/07-config-ops.md
Normal file
213
docs/rebuild-spec/07-config-ops.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 07 · 配置、运维与 SEO
|
||||
|
||||
> **读者**:部署与运维、以及实现配置层的 AI
|
||||
> **前置**:[README.md](README.md)
|
||||
> **源码**:[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
|
||||
|
||||
运维形态可改;下列描述**本分支**行为。
|
||||
|
||||
---
|
||||
|
||||
## 0. 首次安装(Gitea 式)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 锁文件 | `data/install.lock` |
|
||||
| 向导 | `GET/POST /install`(`templates/install.tmpl`) |
|
||||
| 未锁定 | 除 `/install`、`/ssr-assets/*`、`/health` 外重定向到向导 |
|
||||
| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
|
||||
| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
|
||||
| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
|
||||
|
||||
**无 `app.ini`。** 引导仅 CLI / Env。
|
||||
|
||||
---
|
||||
|
||||
## 1. 配置分层与重启边界
|
||||
|
||||
| 层 | 存什么 | 变更方式 | 需重启 |
|
||||
|----|--------|----------|--------|
|
||||
| **Bootstrap** | `DATA`、`HTTP_PORT`/`ADDR`、`DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
|
||||
| **密钥文件** | App HMAC(`data/.jwt_secret`,文件名历史遗留);OIDC RSA **仅启用时**写入 settings(可选遗留文件迁移) | 自动生成 | HMAC 换钥需重启 |
|
||||
| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
|
||||
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 进程引导
|
||||
|
||||
| CLI | 环境变量 | 默认 | 说明 |
|
||||
|-----|----------|------|------|
|
||||
| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
|
||||
| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
|
||||
| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
|
||||
| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
|
||||
| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | `JIANG13_DB_DSN` | (sqlite 默认 `{DATA}/jiang13.db`) | 完整 DSN,优先 |
|
||||
| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接(pg/mysql) |
|
||||
| `--service` | | | install/uninstall/start/stop/restart/status |
|
||||
|
||||
`{DATA}/.jwt_secret`:**App HMAC 密钥**(CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
|
||||
|
||||
浏览器登录:DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT,私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
|
||||
|
||||
业务配置(邮件、OIDC、存储、品牌、敏感词等)在 **DB `forum_settings`**,管理后台热更新。
|
||||
|
||||
### 数据库 Env 示例
|
||||
|
||||
**SQLite(默认):**
|
||||
|
||||
```bash
|
||||
JIANG13_DATA=/data
|
||||
# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
|
||||
```
|
||||
|
||||
**PostgreSQL:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=postgres
|
||||
JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
|
||||
# 或拆分:
|
||||
# JIANG13_DB_HOST=db:5432
|
||||
# JIANG13_DB_USER=forum
|
||||
# JIANG13_DB_PASS=secret
|
||||
# JIANG13_DB_NAME=jiang13
|
||||
# JIANG13_DB_SSLMODE=disable
|
||||
```
|
||||
|
||||
**MySQL / MariaDB:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=mysql
|
||||
JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
|
||||
```
|
||||
|
||||
连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
|
||||
|
||||
### Docker Compose 多库示意
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
environment:
|
||||
JIANG13_DB_TYPE: postgres
|
||||
JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
depends_on: [postgres]
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: forum
|
||||
POSTGRES_PASSWORD: secret
|
||||
POSTGRES_DB: jiang13
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据目录结构
|
||||
|
||||
```text
|
||||
data/
|
||||
├── install.lock # 安装完成锁(与 DB 引擎无关)
|
||||
├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings)
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 遗留:启动时可导入 settings;新源以 DB 为准
|
||||
├── .jwt_secret # App HMAC(CSRF 等;勿提交;非登录 JWT)
|
||||
├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
|
||||
├── uploads/
|
||||
│ ├── avatars/
|
||||
│ ├── posts/
|
||||
│ └── site/
|
||||
└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署方式(现网)
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| 单二进制 | `build.bat` / `make build` → `dist/jiang13(.exe)` |
|
||||
| Docker | 镜像挂载 `/data`;健康检查 `GET /health` |
|
||||
| Compose | `docker compose up -d --build` |
|
||||
| systemd / Windows Service | `--service install` 后启停 |
|
||||
|
||||
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 存储后端
|
||||
|
||||
| type | 行为 |
|
||||
|------|------|
|
||||
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
|
||||
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
|
||||
|
||||
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
|
||||
|
||||
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
|
||||
|
||||
---
|
||||
|
||||
## 5. SEO / 社交分享字段集合
|
||||
|
||||
新站应用真 SSR,但 **meta 字段应对齐**:
|
||||
|
||||
| 字段 | 来源 |
|
||||
|------|------|
|
||||
| `<title>` | 站点 DocumentTitle 或帖标题 |
|
||||
| meta description | 站点简介优先,否则标语;帖文则摘要 |
|
||||
| meta keywords | 站点 keywords |
|
||||
| canonical | 绝对 URL |
|
||||
| og:* / twitter:* | |
|
||||
| JSON-LD | 结构化数据 |
|
||||
| robots | 个别页可 noindex |
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| robots.txt / sitemap.xml | 动态生成 |
|
||||
|
||||
重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
|
||||
|
||||
---
|
||||
|
||||
## 6. 伪静态 Permalink
|
||||
|
||||
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全相关运维注意
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
|
||||
| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
|
||||
| 上传 | 类型/大小限制 |
|
||||
| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
|
||||
| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 健康检查
|
||||
|
||||
`GET /health` → JSON `status`。DB 不可用时非 200。
|
||||
|
||||
---
|
||||
|
||||
## 9. 从旧站迁数据建议
|
||||
|
||||
1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
|
||||
2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
|
||||
3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
|
||||
4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
|
||||
5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
|
||||
|
||||
表语义以 [03-data-model.md](03-data-model.md) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 10. 文档包索引
|
||||
|
||||
返回 [README.md](README.md) 阅读顺序;功能验收用 [02-features.md](02-features.md);规则用 [05-business-rules.md](05-business-rules.md)。
|
||||
105
docs/rebuild-spec/08-gitea-ssr-architecture.md
Normal file
105
docs/rebuild-spec/08-gitea-ssr-architecture.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 08 · Gitea 式 SSR 架构(开发约定)
|
||||
|
||||
> **读者**:在本仓库 `rebuild/gitea-ssr` 分支上开发的 AI / 开发者
|
||||
> **前置**:[README.md](README.md)
|
||||
> **对照上游**:[go-gitea/gitea](https://github.com/go-gitea/gitea)
|
||||
|
||||
---
|
||||
|
||||
## 目标栈(已确认)
|
||||
|
||||
| 层 | 选择 |
|
||||
|----|------|
|
||||
| 公开页渲染 | Go `html/template` **真 SSR** |
|
||||
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
|
||||
| JSON `/api` | **仅机器客户端**(OIDC、health、`/api/captcha`、`/api/register` 等);不服务已迁页面 UI |
|
||||
| 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` |
|
||||
| 发布 | 单二进制 + `go:embed` |
|
||||
| 业务语义 | `01`–`07`;冲突时改代码并回写规格 |
|
||||
| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
|
||||
|
||||
---
|
||||
|
||||
## 分支
|
||||
|
||||
| 分支 | 用途 |
|
||||
|------|------|
|
||||
| `main` | React SPA 对照(git checkout / worktree) |
|
||||
| `rebuild/gitea-ssr` | 唯一重建分支 |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
routers/
|
||||
setup.go
|
||||
install/ # INSTALL_LOCK 未置位时的安装向导
|
||||
web/ # HTML + 表单
|
||||
api/ # 精简机器接口(health / OIDC / robots / sitemap / media)
|
||||
modules/
|
||||
webctx/ # Doer / CSRF / Flash / HTML / Redirect
|
||||
auth/
|
||||
webrender/
|
||||
seo/
|
||||
templates/
|
||||
install.tmpl
|
||||
post-install.tmpl
|
||||
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ messages/
|
||||
services/
|
||||
web_src/ → public/assets/
|
||||
```
|
||||
|
||||
**已删除:** `frontend/`、`embed_static/`、`ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
|
||||
|
||||
**配置:** 引导 = CLI/Env(含 `DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
|
||||
|
||||
**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
|
||||
|
||||
**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
|
||||
|
||||
**后置:** Gitea 仓库同步(不启后台任务)。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
- 锁文件:`data/install.lock`
|
||||
- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
|
||||
- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
|
||||
- 向导不选库:库由启动 Env 决定
|
||||
|
||||
---
|
||||
|
||||
## 渲染与交互原则
|
||||
|
||||
1. 已迁路径「查看源代码」须含内容 DOM。
|
||||
2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
|
||||
3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
|
||||
4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
|
||||
5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
|
||||
|
||||
### 已迁路径(摘要)
|
||||
|
||||
公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论(reply_to/赞/私密)/赞/藏、评论赞、积分解锁 `POST /post/:id/unlock`。
|
||||
|
||||
个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。
|
||||
`POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。
|
||||
|
||||
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
|
||||
|
||||
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
|
||||
|
||||
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
||||
|
||||
右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin `/admin/settings` 可开关排序,友链页 aside 勾选与其共用同一配置。
|
||||
|
||||
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
|
||||
|
||||
站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。
|
||||
|
||||
举报:帖/评 SSR 表单;Admin `/admin/reports`(dismiss / resolve / reject_post / reject_comment)。
|
||||
|
||||
运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。
|
||||
|
||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。
|
||||
52
docs/rebuild-spec/09-ssr-progress.md
Normal file
52
docs/rebuild-spec/09-ssr-progress.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 09 · SSR 重构进度与路线图
|
||||
|
||||
> **读者**:产品方 / 实现 AI
|
||||
> **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照)
|
||||
> **验收清单**: [02-features.md](02-features.md)
|
||||
|
||||
---
|
||||
|
||||
## 当前指针
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| **上一刀** | 首页对齐 main SPA 视觉与交互(草绿壳 + Feed v2) |
|
||||
| **下一刀** | 需点名(移动抽屉 / 帖详情抛光 / OIDC Admin…) |
|
||||
| **工作区** | 应干净 |
|
||||
|
||||
---
|
||||
|
||||
## 一句话状态
|
||||
|
||||
核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、主题、清青产品壳与资源版本号、首页门面密度已齐;本分支 `routers/api` 仅保留机器入口。余量多为后置与体验打磨。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(摘)
|
||||
|
||||
| 域 | 状态 |
|
||||
|----|------|
|
||||
| 五种帖 + Admin P1 + §H | 已迁 |
|
||||
| Limits / 伪静态 / 品牌图 | 已迁 |
|
||||
| 评论嵌套树 UI | 已迁 |
|
||||
| Markdown 工具栏 + preview | 已迁 |
|
||||
| 浅色 / 暗色 / 跟随系统 | 已迁(CSS 媒体查询,无 head 防闪脚本) |
|
||||
| 未挂载论坛 JSON API | 已删(对照 `main`) |
|
||||
| 公开页设计系统 | 已迁(首页对齐 main SPA 草绿壳;[10-design-system.md](10-design-system.md)) |
|
||||
|
||||
---
|
||||
|
||||
## 未完成
|
||||
|
||||
- P3:游客评论、修订 diff、响应式侧栏抽屉
|
||||
- 编辑器后置:TipTap、表格、图片组、表情贴纸
|
||||
- 后置:OIDC Admin UI、S3 热切换、Gitea `/projects`
|
||||
|
||||
---
|
||||
|
||||
## 如何查阅
|
||||
|
||||
| 问题 | 看哪里 |
|
||||
|------|--------|
|
||||
| 某功能做没做? | [02-features.md](02-features.md) |
|
||||
| 现在该干什么? | 本文「当前指针」 |
|
||||
94
docs/rebuild-spec/10-design-system.md
Normal file
94
docs/rebuild-spec/10-design-system.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 10 · 公开页设计系统
|
||||
|
||||
> **读者**:实现公开页视觉 / CSS 的 AI
|
||||
> **前置**:[01-product.md](01-product.md)、[06-pages-ux.md](06-pages-ux.md)
|
||||
> **实现**:[`web_src/css/site.css`](../../web_src/css/site.css)、[`templates/base/`](../../templates/base/)
|
||||
> **品牌**:默认「姜十三论坛」;首页视觉对齐 `main` SPA 草绿产品壳
|
||||
|
||||
视觉可重设;**信息架构与关键操作流以 06 为准**。本文件只约定公开页壳层与内容表面的视觉语言。
|
||||
|
||||
静态资源须带构建版本号:`/ssr-assets/site.css?v={{.AssetVersion}}`(长缓存 + query 穿透,对齐 Gitea)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 气质
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 关键词 | 对齐 `main` SPA:草绿 `#18a058`、清新产品壳 |
|
||||
| 密度 | SPA 同款顶栏工具条 + Feed `post-row--v2` |
|
||||
| 反模式 | 紫渐变、暖奶油底、大圆角卡片墙、全幅 hero、Inter/Roboto |
|
||||
|
||||
---
|
||||
|
||||
## 2. Token
|
||||
|
||||
### 2.1 色(CSS 变量 `--j13-*`)
|
||||
|
||||
| Token | 浅色 | 暗色 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| `--j13-bg` / page | `#f5f7fa` | `#141416` | 页底 |
|
||||
| `--j13-surface` | `#ffffff` | `#1f1f23` | 顶栏、主栏 |
|
||||
| `--j13-accent` / green | `#18a058` | `#23c36b` | 主色(对齐 main SPA) |
|
||||
| `--j13-accent-ink` | `#138f4c` | `#6ee7a0` | 强强调 |
|
||||
| `--j13-soft` / green-bg | `#edfbf3` / `rgba(24,160,88,.08)` | 绿 soft | active / hover |
|
||||
| `--j13-border` | `#e8edf2` | `#2e2e32` | 分隔 |
|
||||
|
||||
语义色(ok / warn / err / pin / feat)随主题成对定义;feat 用青 soft,不用草绿。
|
||||
|
||||
### 2.2 字号与字重
|
||||
|
||||
| 阶 | 尺寸 | 用途 |
|
||||
|----|------|------|
|
||||
| `xs` | 0.72–0.75rem | 口号、辅助 |
|
||||
| `sm` | 0.8125rem | 元信息、侧栏、顶栏次级 |
|
||||
| `md` | 0.9375rem | 正文默认 |
|
||||
| `lg` | 1.15rem | Feed 标题 |
|
||||
| `xl` | 1.35rem | 帖标题 |
|
||||
| `brand` | 1.3rem / 700 | 顶栏站点名 |
|
||||
|
||||
字体栈:**中文优先** PingFang SC / Hiragino / Microsoft YaHei / Noto Sans SC(与 SPA 一致,确保本机可见);代码 Cascadia / Consolas。不引入 Inter/Roboto,不依赖本机 IBM Plex。
|
||||
|
||||
### 2.3 间距与圆角
|
||||
|
||||
- 栅格:4px 基准;总宽 `--j13-frame: 1320px`
|
||||
- 圆角:`--j13-radius: 8px`;搜索 pill / 排序分段可用满圆角
|
||||
- 阴影:sticky 顶栏微阴影
|
||||
|
||||
### 2.4 动效
|
||||
|
||||
| 处 | 行为 |
|
||||
|----|------|
|
||||
| 主题 | `color` / `background` / `border-color` `150ms ease` |
|
||||
| Feed 行 | hover 背景 |
|
||||
| 顶栏 | sticky 阴影;搜索 focus soft ring |
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局
|
||||
|
||||
| 断点 | 行为 |
|
||||
|------|------|
|
||||
| ≥901px | 三栏:左 210px / 中 1fr / 右 280px;总宽 ~1400px(对齐 SPA) |
|
||||
| ≤900px | 隐藏右栏;顶栏搜索折行(抽屉化 **下一刀**) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 组件约定
|
||||
|
||||
| 组件 | 规则 |
|
||||
|------|------|
|
||||
| 顶栏 | SPA:品牌 \| 搜索胶囊(筛选钮 + Ctrl+K)\| 发帖绿钮 \| 主题/私信/头像菜单 |
|
||||
| 左栏 | 浏览 / 板块;active 绿底;板块色槽 + 帖数 |
|
||||
| Feed 行 | `post-row--v2`:头像 · 徽章+标题 · 摘要(依 feed_list_style)· 元信息 · 回复数 |
|
||||
| 高级搜索 | 顶栏筛选打开;有条件时 details 展开 |
|
||||
| 排序 | SPA `feed-sort-tab` 绿 active |
|
||||
|
||||
---
|
||||
|
||||
## 5. 范围边界
|
||||
|
||||
- **覆盖**:公开页壳层(含首页门面密度)、资源 `?v=`、浅/暗色
|
||||
- **不覆盖**:Admin 深重绘、移动抽屉、虚拟滚动、TipTap、改站名
|
||||
|
||||
交叉:[06-pages-ux.md](06-pages-ux.md) · [09-ssr-progress.md](09-ssr-progress.md)
|
||||
140
docs/rebuild-spec/README.md
Normal file
140
docs/rebuild-spec/README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 姜十三论坛 · 重构规格文档包
|
||||
|
||||
> **读者**:准备用新栈(建议真 SSR)重写站点的 AI / 开发者
|
||||
> **事实来源**:本仓库现有代码;规格描述「产品必须保留什么」,不是「必须继续用 Go + React SPA」
|
||||
> **交叉引用**:[01-product](01-product.md) · [02-features](02-features.md) · [03-data-model](03-data-model.md) · [04-api](04-api.md) · [05-business-rules](05-business-rules.md) · [06-pages-ux](06-pages-ux.md) · [07-config-ops](07-config-ops.md) · [08-gitea-ssr-architecture](08-gitea-ssr-architecture.md) · [09-ssr-progress](09-ssr-progress.md) · [10-design-system](10-design-system.md)
|
||||
|
||||
> **实现栈(重构分支)**:Gitea 式 Go 模板 SSR;开发分支 `rebuild/gitea-ssr`;`main` 保留 React SPA 对照。详见 [08](08-gitea-ssr-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
## 阅读顺序(请按序投喂)
|
||||
|
||||
| 顺序 | 文件 | 用途 |
|
||||
|------|------|------|
|
||||
| 1 | 本文 `README.md` | 架构痛点、重构约束、术语 |
|
||||
| 2 | [01-product.md](01-product.md) | 产品定位、角色、模块地图 |
|
||||
| 3 | [02-features.md](02-features.md) | 验收级功能清单(可打勾) |
|
||||
| 4 | [03-data-model.md](03-data-model.md) | 表结构、枚举、设置键、等级徽章 |
|
||||
| 5 | [04-api.md](04-api.md) | HTTP 合约(路径 / 鉴权 / 请求响应) |
|
||||
| 6 | [05-business-rules.md](05-business-rules.md) | 状态机与数值规则 |
|
||||
| 7 | [06-pages-ux.md](06-pages-ux.md) | 路由、布局、编辑器、后台流程 |
|
||||
| 8 | [07-config-ops.md](07-config-ops.md) | 配置、数据目录、部署、SEO 字段 |
|
||||
| 9 | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) | Gitea 式 SSR 分支与目录约定 |
|
||||
| 10 | [09-ssr-progress.md](09-ssr-progress.md) | **进度与刀序**(「现在做到哪 / 下一刀」) |
|
||||
| 11 | [10-design-system.md](10-design-system.md) | 公开页视觉 token / 组件(壳层重绘) |
|
||||
|
||||
单次 context 不够时:先投喂 `README` + `01` + `02`;跟进度看 `09`;实现某模块时再追加对应 `03`–`06` 章节。
|
||||
|
||||
---
|
||||
|
||||
## 当前产品是什么
|
||||
|
||||
**姜十三论坛(Jiang13 Forum)** 面向小圈子 / 团队 / 同好社群的轻量论坛。
|
||||
|
||||
当前实现技术栈(**可抛弃,仅作对照**):
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 后端 | Go · Gin · GORM · SQLite |
|
||||
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
|
||||
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
|
||||
| 认证 | bcrypt + DB opaque session Cookie(`jiang13_session`) |
|
||||
|
||||
演示站:https://bbs.iioio.com/
|
||||
|
||||
---
|
||||
|
||||
## 为何要重构:架构痛点(必须打破)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
browser[Browser]
|
||||
spa[ReactSPA]
|
||||
gin[GinAPI]
|
||||
sqlite[SQLite]
|
||||
bot[BotHTML]
|
||||
browser -->|"用户"| spa
|
||||
spa -->|"JSON /api"| gin
|
||||
gin --> sqlite
|
||||
browser -->|"爬虫 UA"| bot
|
||||
bot --> gin
|
||||
```
|
||||
|
||||
| 痛点 | 现状 | 对用户的影响 |
|
||||
|------|------|----------------|
|
||||
| 非真 SSR | 生产入口(`main` 的 `embed_static`)只注入 title / branding / Open Graph,**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
|
||||
| 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
|
||||
| 无正式 migration | Schema 靠 GORM `AutoMigrate`([`models/db.go`](../../models/db.go)) | 升级靠「加字段」,难做破坏性迁移与审计 |
|
||||
| Cookie JWT(旧) | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session`;`.jwt_secret` 仅 CSRF/HMAC |
|
||||
|
||||
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
|
||||
|
||||
---
|
||||
|
||||
## 重构时必须保留 vs 可以改
|
||||
|
||||
### 必须保留(产品语义)
|
||||
|
||||
- [02-features.md](02-features.md) 中列出的功能能力
|
||||
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
|
||||
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
|
||||
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
|
||||
|
||||
### 建议兼容(降低迁移成本)
|
||||
|
||||
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
|
||||
- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
|
||||
- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
|
||||
|
||||
### 可以彻底改
|
||||
|
||||
- 语言与框架(不必再 Go + React SPA)
|
||||
- 单二进制 / `go:embed`(可改为前后端分离部署)
|
||||
- SQLite(可换 PostgreSQL 等;规格不强制)
|
||||
- UI 视觉(布局信息密度见 [06-pages-ux.md](06-pages-ux.md),视觉可重设)
|
||||
- 爬虫专用 HTML 双轨(用真 SSR 取代)
|
||||
|
||||
---
|
||||
|
||||
## 术语表(首次出现)
|
||||
|
||||
| 术语 | 中文 | 说明 |
|
||||
|------|------|------|
|
||||
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
|
||||
| SPA | 单页应用 | 当前前台实现形态 |
|
||||
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
|
||||
| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
|
||||
| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
|
||||
| Feed | 信息流 | 首页 / 板块帖列表 |
|
||||
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
|
||||
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
|
||||
|
||||
---
|
||||
|
||||
## 源码速查(核对规格时)
|
||||
|
||||
| 主题 | 路径 |
|
||||
|------|------|
|
||||
| 路由总装 | [`routers/setup.go`](../../routers/setup.go) |
|
||||
| GORM 模型 | [`models/models.go`](../../models/models.go) |
|
||||
| AutoMigrate | [`models/db.go`](../../models/db.go) |
|
||||
| 论坛设置键 | [`services/settings.go`](../../services/settings.go) |
|
||||
| SSR 页面路由 | [`routers/web/`](../../routers/web/) |
|
||||
| JSON API | [`routers/api/`](../../routers/api/) |
|
||||
| 前端 API / 页面(对照) | 仅 `main`:`frontend/src/api/`、`frontend/src/App.tsx` |
|
||||
| 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) |
|
||||
|
||||
---
|
||||
|
||||
## 文档包完成标准
|
||||
|
||||
另一 AI 仅阅读本目录、**不打开业务源码**,应能:
|
||||
|
||||
1. 列出全部用户可见功能与后台能力
|
||||
2. 画出核心表 ER 并理解枚举
|
||||
3. 实现或 mock 与现网兼容的 API 形状
|
||||
4. 复现审核 / 积分 / 门控 / 特殊帖规则
|
||||
5. 搭出等价的页面信息架构与关键交互
|
||||
|
||||
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。
|
||||
22
docs/rebuild-spec/plans/admin-badges.md
Normal file
22
docs/rebuild-spec/plans/admin-badges.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Admin 徽章管理(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/badges`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 列表(含停用) | `BadgeService.ListDefs(true)` | `GET /admin/badges` |
|
||||
| 创建/更新定义 | `UpsertDef` | `POST /admin/badges`、`POST /admin/badges/:id` |
|
||||
| 删除定义 | `DeleteDef`(清 `user_badges`) | `POST /admin/badges/:id/delete` |
|
||||
| 颁发限定 | `AwardLimited` | `POST /admin/badges/award` |
|
||||
| 收回 | `Revoke` | `POST /admin/badges/revoke` |
|
||||
|
||||
## 不做
|
||||
|
||||
自动徽章规则引擎调度 UI(P2,服务层 `EvaluateAuto` 仍可被触发)、等级设定。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。
|
||||
21
docs/rebuild-spec/plans/admin-media.md
Normal file
21
docs/rebuild-spec/plans/admin-media.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Admin 媒体库(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/media`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 分类/搜索/分页列表 | `UploadStore.ListMedia` | `GET /admin/media` |
|
||||
| 单删 | `DeleteMediaByIDs` | `POST /admin/media/:id/delete` |
|
||||
| 批量删 | 同上 | `POST /admin/media/delete` |
|
||||
| 同步索引 | `SyncMediaIndex` | `POST /admin/media/sync` |
|
||||
|
||||
## 不做
|
||||
|
||||
S3 热切换 Admin、全站未引用扫描清理、WebP 产品化默认策略。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:写入 uploads → 同步索引 → 列表可见 → 删除。
|
||||
30
docs/rebuild-spec/plans/admin-users.md
Normal file
30
docs/rebuild-spec/plans/admin-users.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Admin 用户管理(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/users`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
在 Admin 增加用户列表与运营操作,复用已有服务层,不恢复浏览器管理 JSON `/api`。
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 列表/搜索/分页 | `UserService.ListUsers` | `GET /admin/users` |
|
||||
| 禁言/解禁 | `UserService.BanUser` | `POST /admin/users/:id/ban` |
|
||||
| 认证开关 | `SetVerified` | `POST /admin/users/:id/verify` |
|
||||
| 调积分 | `PointsService.AdminAdjust` | `POST /admin/users/:id/points` |
|
||||
|
||||
## 实现要点
|
||||
|
||||
1. [`routers/web/home.go`](../routers/web/home.go) admin 组注册上述路由
|
||||
2. 新建 [`routers/web/admin_users.go`](../routers/web/admin_users.go):渲染 + CSRF POST + PRG
|
||||
3. [`templates/admin/users.tmpl`](../templates/admin/users.tmpl) + [`templates/admin/nav.tmpl`](../templates/admin/nav.tmpl) 入口
|
||||
4. 回写 `02` §H(调积分/认证相关)、`06` `/admin/users` 已迁;更新 `09` 当前指针 → badges
|
||||
|
||||
## 不做
|
||||
|
||||
等级设定 UI、徽章授予、批量导入、恢复 `/api/admin/users` 作 UI。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:搜索用户 → 禁言 → 调积分 → 切换 verified。
|
||||
17
docs/rebuild-spec/plans/level-badges-display.md
Normal file
17
docs/rebuild-spec/plans/level-badges-display.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# P2 等级设定与徽章展示 — 实现计划
|
||||
|
||||
> 状态:已完成
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 实现 |
|
||||
|------|------|
|
||||
| Admin 设等级 | `POST /admin/users/:id/level` → `SetUserLevel`(Exp 调至门槛) |
|
||||
| 公开等级 | 既有 `/user/:id`、`/profile` Lv/Exp |
|
||||
| 徽章展示 | 用户页 / 资料页列表;访问时 `EvaluateAuto` |
|
||||
| 防刷分成 | 确认 `suspiciousUnlockPair` 已落地并勾选 `02` |
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:设用户 Lv5 → Exp=200;用户页可见等级。
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
| 改什么 | 去哪里 |
|
||||
| --- | --- |
|
||||
| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) |
|
||||
| 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
|
||||
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
|
||||
|
||||
逛完板块,发第一帖或回一楼——聊起来就对了。
|
||||
@@ -98,12 +98,12 @@
|
||||
### 浏览与发帖
|
||||
|
||||
- 三栏布局,桌面 / 移动自适应
|
||||
- Feed 排序与虚拟滚动
|
||||
- 浅色 / 暗色主题;板块图标与主题色可配
|
||||
- TipTap 富文本、标签、正文图片
|
||||
- 楼层评论:回复、引用、@
|
||||
- Feed 排序
|
||||
- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
|
||||
- Markdown 编辑器、标签、正文图片、内容门控
|
||||
- 楼层评论:回复、引用、嵌套树
|
||||
- 点赞、收藏;置顶 / 精华
|
||||
- 编辑修订历史(可对比差异)
|
||||
- 编辑修订历史
|
||||
- 可配置普通用户的编辑时限
|
||||
|
||||
### 社交与个人
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
### 管理后台
|
||||
|
||||
与前台同一套 React 体验,统一在 `/admin`:
|
||||
SSR 后台统一在 `/admin`:
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
1. 编译得到一个二进制
|
||||
2. 放到目录里运行
|
||||
3. 自动生成 `app.ini`
|
||||
3. 自动生成 `data/.jwt_secret`
|
||||
4. 浏览器注册,第一个账号即管理员
|
||||
|
||||
### 它补哪块空缺
|
||||
@@ -165,14 +165,14 @@
|
||||
| --- | --- |
|
||||
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
|
||||
| 内置 SQLite | 零外部数据库,数据在本地目录 |
|
||||
| 精简 `app.ini` | 主要管端口、数据目录、JWT |
|
||||
| Env / CLI 引导 | 端口、数据目录、数据库 |
|
||||
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
|
||||
| 系统服务 | 内置 Linux systemd / Windows Service |
|
||||
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
|
||||
|
||||
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
|
||||
|
||||
进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。
|
||||
进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,77 +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__
|
||||
)
|
||||
|
||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||
func SetSPADocumentTitle(fn func() string) {
|
||||
spaBrandTitleFn = fn
|
||||
}
|
||||
|
||||
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
||||
func SetSPABrandingJSON(fn func() []byte) {
|
||||
spaBrandJSONFn = 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) {
|
||||
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 == "/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, "/oauth") ||
|
||||
strings.HasPrefix(path, "/.well-known") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package embed_static
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 哈希
|
||||
c.Header("Cache-Control", "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>"))
|
||||
}
|
||||
|
||||
var head strings.Builder
|
||||
writeMeta(&head, "description", meta.Description)
|
||||
writeMeta(&head, "keywords", meta.Keywords)
|
||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||
}
|
||||
robots := strings.TrimSpace(meta.Robots)
|
||||
if robots != "" {
|
||||
writeMeta(&head, "robots", robots)
|
||||
}
|
||||
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
writeProp(&head, "og:type", ogType)
|
||||
writeProp(&head, "og:site_name", meta.SiteName)
|
||||
writeProp(&head, "og:locale", locale)
|
||||
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
|
||||
writeProp(&head, "og:description", meta.Description)
|
||||
writeProp(&head, "og:url", meta.Canonical)
|
||||
writeProp(&head, "og:image", meta.OGImage)
|
||||
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
|
||||
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||
writeMetaName(&head, "twitter:description", meta.Description)
|
||||
writeMetaName(&head, "twitter:image", meta.OGImage)
|
||||
|
||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||
head.WriteString(`<script type="application/ld+json">`)
|
||||
head.WriteString(jsonld)
|
||||
head.WriteString(`</script>`)
|
||||
}
|
||||
|
||||
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
|
||||
if boot := spaBrandingBootScript(); boot != "" {
|
||||
head.WriteString(boot)
|
||||
}
|
||||
|
||||
if head.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||
func spaBrandingBootScript() string {
|
||||
if spaBrandJSONFn == nil {
|
||||
return ""
|
||||
}
|
||||
raw := bytes.TrimSpace(spaBrandJSONFn())
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
// 防止 JSON 字符串中的 </script> 提前闭合标签
|
||||
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 ""
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.site-footer { flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.aside-panel { width: 280px; flex-shrink: 0; }
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4415
frontend/package-lock.json
generated
4415
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "jiang13-forum-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
"@tiptap/extension-table": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
"@tiptap/starter-kit": "^3.26.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Suspense } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
RouterProvider,
|
||||
Route,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import './styles/global.css';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ThemeProvider } from './hooks/useTheme';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import AppRouteError from './components/AppRouteError';
|
||||
import PageLoader from './components/PageLoader';
|
||||
import AuthPageFallback from './components/AuthPageFallback';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import PullToRefresh from './components/PullToRefresh';
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||
|
||||
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
||||
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
|
||||
const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
|
||||
const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
|
||||
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
|
||||
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
|
||||
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
|
||||
const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
|
||||
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
||||
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route errorElement={<AppRouteError />}>
|
||||
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/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="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="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
||||
</Route>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" 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="/messages" element={<MessagesPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<PullToRefresh />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(BASE + url, {
|
||||
credentials: 'same-origin',
|
||||
...opts,
|
||||
headers: {
|
||||
...(opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...opts.headers,
|
||||
},
|
||||
});
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new Error('服务器响应异常');
|
||||
}
|
||||
if (!res.ok) throw new Error((data.error as string) || '请求失败');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||
projects: (params?: { page?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
const qs = q.toString();
|
||||
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/projects${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
posts: (params: Record<string, string | number>) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||
tags: (limit = 40) => request<{ tags: TagCount[] }>(`/api/tags?limit=${limit}`),
|
||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||
},
|
||||
comments: (id: number, myIds?: number[]) => {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
},
|
||||
recentComments: () => request<{ comments: RecentComment[] }>('/api/comments/recent'),
|
||||
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
||||
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateBoard: (id: number, body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
|
||||
// 管理后台 API
|
||||
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
||||
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
||||
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
if (params.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
posts: PostItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
pending_count?: number;
|
||||
status?: string;
|
||||
}>(`/api/admin/posts${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminApprovePost: (id: number) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
|
||||
adminPinPost: (id: number, pinned: boolean) =>
|
||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
||||
method: 'POST', body: JSON.stringify({ pinned }),
|
||||
}),
|
||||
adminFeaturePost: (id: number, featured: boolean) =>
|
||||
request<{ message: string; featured: boolean }>(`/api/admin/posts/${id}/feature`, {
|
||||
method: 'POST', body: JSON.stringify({ featured }),
|
||||
}),
|
||||
adminLockPost: (id: number, locked: boolean) =>
|
||||
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminRejectPost: (id: number, reason: string) =>
|
||||
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
|
||||
method: 'POST', body: JSON.stringify({ reason }),
|
||||
}),
|
||||
adminReports: (params?: { page?: number; status?: ReportStatus | 'all' | string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
reports: PostReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
pending_count: number;
|
||||
status: string;
|
||||
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminHandleReport: (id: number, body: {
|
||||
action: 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
|
||||
handle_note?: string;
|
||||
reject_reason?: string;
|
||||
}) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/admin/reports/${id}/handle`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateForumSettings: (body: ForumLimits) =>
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateMailSettings: (body: MailConfig) =>
|
||||
request<{ message: string; mail: MailConfig }>('/api/admin/settings/mail', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateOIDCSettings: (body: OIDCConfig) =>
|
||||
request<{ message: string; oidc: OIDCConfig }>('/api/admin/settings/oidc', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateGiteaSettings: (body: GiteaSyncConfig) =>
|
||||
request<{ message: string; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminSyncGitea: () =>
|
||||
request<{ message: string; count: number; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
adminUpdateStorageSettings: (body: StorageConfig) =>
|
||||
request<{ message: string; storage: StorageConfig }>('/api/admin/settings/storage', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateBranding: (body: SiteBranding) =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUploadBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image', file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('kind', kind);
|
||||
fd.append('file', file);
|
||||
return request<{ message: string; url: string; branding: SiteBranding }>(
|
||||
'/api/admin/settings/branding/upload',
|
||||
{ method: 'POST', body: fd, headers: {} },
|
||||
);
|
||||
},
|
||||
adminClearBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image') =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding/clear', {
|
||||
method: 'POST', body: JSON.stringify({ kind }),
|
||||
}),
|
||||
adminListOAuthClients: () =>
|
||||
request<{ clients: OAuthClient[] }>('/api/admin/oauth/clients'),
|
||||
adminCreateOAuthClient: (body: OAuthClientInput) =>
|
||||
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>('/api/admin/oauth/clients', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateOAuthClient: (id: number, body: OAuthClientInput) =>
|
||||
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminDeleteOAuthClient: (id: number) =>
|
||||
request<{ message: string; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
adminTestMail: (to: string) =>
|
||||
request<{ message: string }>('/api/admin/settings/mail/test', {
|
||||
method: 'POST', body: JSON.stringify({ to }),
|
||||
}),
|
||||
adminUpdateFilterWords: (content: string) =>
|
||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||
method: 'PUT', body: JSON.stringify({ content }),
|
||||
}),
|
||||
postRevisions: (id: number) =>
|
||||
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' }),
|
||||
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
const qs = q.toString();
|
||||
return request<{ posts: (PostItem & { deleted_at: string })[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/posts/trash${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
adminRestorePost: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/posts/${id}/restore`, { method: 'POST' }),
|
||||
adminPurgePost: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/posts/${id}/purge`, { method: 'DELETE' }),
|
||||
adminComments: (params?: { page?: number; status?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
comments: Comment[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
pending_count?: number;
|
||||
}>(`/api/admin/comments${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminApproveComment: (id: number) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/comments/${id}/approve`, { method: 'POST' }),
|
||||
adminRejectComment: (id: number, reason?: string) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/comments/${id}/reject`, {
|
||||
method: 'POST', body: JSON.stringify({ reason: reason || '' }),
|
||||
}),
|
||||
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
||||
adminTrashComments: (params?: { page?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.keyword) q.set('keyword', params.keyword);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
comments: (Comment & { deleted_at: string })[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}>(`/api/admin/comments/trash${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminRestoreComment: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/comments/${id}/restore`, { method: 'POST' }),
|
||||
adminPurgeComment: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/comments/${id}/purge`, { method: 'DELETE' }),
|
||||
adminCommentRevisions: (id: number) =>
|
||||
request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`),
|
||||
adminUsers: (page = 1, opts?: { keyword?: string; filter?: string }) => {
|
||||
const q = new URLSearchParams({ page: String(page) });
|
||||
if (opts?.keyword?.trim()) q.set('keyword', opts.keyword.trim());
|
||||
if (opts?.filter && opts.filter !== 'all') q.set('filter', opts.filter);
|
||||
return request<{ users: User[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/users?${q}`,
|
||||
);
|
||||
},
|
||||
adminBanUser: (id: number, banned: boolean) =>
|
||||
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
|
||||
method: 'POST', body: JSON.stringify({ banned }),
|
||||
}),
|
||||
adminVerifyUser: (id: number, verified: boolean) =>
|
||||
request<{ message: string; verified: boolean }>(`/api/admin/users/${id}/verify`, {
|
||||
method: 'POST', body: JSON.stringify({ verified }),
|
||||
}),
|
||||
adminSetUserLevel: (id: number, level: number) =>
|
||||
request<{ message: string; level: number; exp: number }>(`/api/admin/users/${id}/level`, {
|
||||
method: 'POST', body: JSON.stringify({ level }),
|
||||
}),
|
||||
adminAdjustPoints: (id: number, delta: number, note?: string) =>
|
||||
request<{ message: string; points: number }>(`/api/admin/users/${id}/points`, {
|
||||
method: 'POST', body: JSON.stringify({ delta, note: note || '' }),
|
||||
}),
|
||||
adminListBadges: () => request<{ badges: BadgeDef[] }>('/api/admin/badges'),
|
||||
adminUpsertBadge: (badge: Partial<BadgeDef>) =>
|
||||
request<{ message: string; badge: BadgeDef }>('/api/admin/badges', {
|
||||
method: 'POST', body: JSON.stringify(badge),
|
||||
}),
|
||||
adminAwardBadge: (userId: number, badgeId: number, revoke = false) =>
|
||||
request<{ message: string }>(`/api/admin/users/${userId}/badges`, {
|
||||
method: 'POST', body: JSON.stringify({ badge_id: badgeId, revoke }),
|
||||
}),
|
||||
mePoints: (page = 1) =>
|
||||
request<{
|
||||
points: number;
|
||||
creator_income_total: number;
|
||||
ledger: PointLedger[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
check_in: CheckInStatus;
|
||||
lottery: LotteryStatus;
|
||||
}>(`/api/me/points?page=${page}`),
|
||||
checkIn: () =>
|
||||
request<{ message: string; check_in: CheckInStatus; points: number }>('/api/me/check-in', { method: 'POST' }),
|
||||
lotteryStatus: () => request<{ lottery: LotteryStatus }>('/api/me/lottery'),
|
||||
lotteryDraw: () =>
|
||||
request<{ message: string; lottery: LotteryStatus; points: number }>('/api/me/lottery', { method: 'POST' }),
|
||||
unlockPostBlock: (postId: number, blockKey: string) =>
|
||||
request<{ message: string; unlock: { block_key: string; cost: number; points_balance: number; inner_html: string } }>(
|
||||
`/api/posts/${postId}/unlock`,
|
||||
{ method: 'POST', body: JSON.stringify({ block_key: blockKey }) },
|
||||
),
|
||||
adminMedia: (params?: { category?: string; page?: number; size?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.category) sp.set('category', params.category);
|
||||
if (params?.page) sp.set('page', String(params.page));
|
||||
if (params?.size) sp.set('size', String(params.size));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return request<MediaListResult>(`/api/admin/media${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminDeleteMedia: (urls: string[]) =>
|
||||
request<{ message: string; deleted: number }>('/api/admin/media/delete', {
|
||||
method: 'POST', body: JSON.stringify({ urls }),
|
||||
}),
|
||||
adminBackup: () =>
|
||||
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
|
||||
profileStats: () => request<{ stats: UserActivityStats }>('/api/profile/stats'),
|
||||
userProfile: (id: number) =>
|
||||
request<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${id}`),
|
||||
updateNickname: (nickname: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('nickname', nickname);
|
||||
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
updateSignature: (signature: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('signature', signature);
|
||||
return request<{ message: string; user: User }>('/api/profile/signature', {
|
||||
method: 'POST', body: fd, headers: {},
|
||||
});
|
||||
},
|
||||
updatePassword: (oldPassword: string, newPassword: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('old_password', oldPassword);
|
||||
fd.append('new_password', newPassword);
|
||||
return request('/api/profile/password', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
uploadAvatar: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('avatar', file);
|
||||
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
uploadPostImage: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
createPost: (data: { board_id: string; title: string; content: string; tags?: string; post_type?: string }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('board_id', data.board_id);
|
||||
fd.append('title', data.title);
|
||||
fd.append('content', data.content);
|
||||
fd.append('tags', data.tags || '');
|
||||
fd.append('post_type', data.post_type || 'normal');
|
||||
return request<{ post_id: number; message?: string; status?: string }>('/api/posts', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number; post_type?: string }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('title', data.title);
|
||||
fd.append('content', data.content);
|
||||
fd.append('tags', data.tags || '');
|
||||
if (data.board_id != null && data.board_id !== '') {
|
||||
fd.append('board_id', String(data.board_id));
|
||||
}
|
||||
if (data.post_type) {
|
||||
fd.append('post_type', data.post_type);
|
||||
}
|
||||
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
setQuestionResolved: (id: number, resolved: boolean) => {
|
||||
const fd = new FormData();
|
||||
fd.append('resolved', resolved ? '1' : '0');
|
||||
return request<{ message: string; question_resolved: boolean }>(`/api/posts/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: {},
|
||||
});
|
||||
},
|
||||
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
|
||||
login: (username: string, password: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', username);
|
||||
fd.append('password', password);
|
||||
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
register: (data: {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
emailCode?: string;
|
||||
}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', data.username);
|
||||
fd.append('password', data.password);
|
||||
fd.append('nickname', data.nickname);
|
||||
fd.append('email', data.email);
|
||||
if (data.emailCode) fd.append('email_code', data.emailCode);
|
||||
return request('/api/register', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
registerConfig: () => request<RegisterConfig>('/api/register/config'),
|
||||
sendRegisterEmailCode: (email: string) =>
|
||||
request<{ message: string }>('/api/register/email-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||
logout: () => request('/api/logout', { 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' }),
|
||||
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
|
||||
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
reportComment: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/comments/${id}/report`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
messageConversations: (params?: { page?: number; size?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.size) q.set('size', String(params.size));
|
||||
const qs = q.toString();
|
||||
return request<{ conversations: MessageConversation[]; total: number; page: number }>(
|
||||
`/api/messages/conversations${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
conversationMessages: (peerId: number, params?: { size?: number; before?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.size) q.set('size', String(params.size));
|
||||
if (params?.before) q.set('before', String(params.before));
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
messages: PrivateMessage[];
|
||||
total: number;
|
||||
peer_user_id: number;
|
||||
peer_user?: User;
|
||||
is_system: boolean;
|
||||
}>(`/api/messages/conversations/${peerId}${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
markConversationRead: (peerId: number) =>
|
||||
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
|
||||
messageUnreadCount: () => request<{ count: number }>('/api/messages/unread-count'),
|
||||
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
|
||||
request<{ message: PrivateMessage }>('/api/messages', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
markAllMessagesRead: () =>
|
||||
request<{ message: string }>('/api/messages/read-all', { method: 'POST' }),
|
||||
addComment: (postId: number, data: {
|
||||
content: string;
|
||||
replyTo?: number;
|
||||
isPrivate?: boolean;
|
||||
}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('content', data.content);
|
||||
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
|
||||
if (data.isPrivate) fd.append('is_private', '1');
|
||||
return request<{ message: string; floor: number; id: number; status?: string }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
updateComment: (id: number, content: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('content', content);
|
||||
return request<{ message: string; content: string; status?: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -1,477 +0,0 @@
|
||||
export interface UserBadge {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
nickname: string;
|
||||
signature?: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
verified?: boolean;
|
||||
exp?: number;
|
||||
level?: number;
|
||||
points?: number;
|
||||
creator_income_total?: number;
|
||||
badges?: UserBadge[];
|
||||
banned?: boolean;
|
||||
banned_at?: string;
|
||||
last_login_at?: string;
|
||||
last_login_ip?: string;
|
||||
last_access_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 公开用户主页(无邮箱) */
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
signature: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
verified?: boolean;
|
||||
exp?: number;
|
||||
level?: number;
|
||||
creator_income_total?: number;
|
||||
badges?: UserBadge[];
|
||||
banned?: boolean;
|
||||
banned_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 个人中心活动统计 */
|
||||
export interface UserActivityStats {
|
||||
post_count: number;
|
||||
comment_count: number;
|
||||
favorite_count: number;
|
||||
like_received: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color_index?: number;
|
||||
sort_order: number;
|
||||
post_count?: number;
|
||||
}
|
||||
|
||||
export interface ForumStats {
|
||||
users: number;
|
||||
posts: number;
|
||||
boards: number;
|
||||
}
|
||||
|
||||
/** 标签云单项 */
|
||||
export interface TagCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PostItem {
|
||||
id: number;
|
||||
board_id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
content?: string;
|
||||
tags: string;
|
||||
/** normal=讨论 | question=问答 */
|
||||
post_type?: 'normal' | 'question' | string;
|
||||
/** 仅问答帖有意义 */
|
||||
question_resolved?: boolean;
|
||||
pinned: boolean;
|
||||
featured?: boolean;
|
||||
edit_locked?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
last_reply_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
board?: Board;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface PostRevision {
|
||||
id: number;
|
||||
post_id: number;
|
||||
editor_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
created_at: string;
|
||||
editor?: User;
|
||||
}
|
||||
|
||||
export interface CommentRevision {
|
||||
id: number;
|
||||
comment_id: number;
|
||||
editor_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
editor?: User;
|
||||
}
|
||||
|
||||
export interface PostDetailResponse {
|
||||
post: PostItem;
|
||||
comment_count: number;
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
/** 当前用户是否已在本帖发表过评论(含审核中) */
|
||||
has_replied?: boolean;
|
||||
can_edit?: boolean;
|
||||
edit_block_reason?: string;
|
||||
is_edited?: boolean;
|
||||
post_edit_window_hours?: number;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
post_id: number;
|
||||
user_id: number;
|
||||
floor: number;
|
||||
content: string;
|
||||
reply_to?: number;
|
||||
/** 嵌套展示父评论(父评论不可见时可能回挂到祖先) */
|
||||
thread_parent_id?: number;
|
||||
guest_nick?: string;
|
||||
guest_email?: string;
|
||||
guest_url?: string;
|
||||
is_private?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
content_hidden?: boolean;
|
||||
like_count?: number;
|
||||
liked?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user?: User;
|
||||
post?: PostItem;
|
||||
reply_target?: Comment;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
users: number;
|
||||
posts: number;
|
||||
boards: number;
|
||||
comments: number;
|
||||
pending_posts?: number;
|
||||
pending_comments?: number;
|
||||
pending_reports?: number;
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
export interface ForumLimits {
|
||||
post_edit_window_hours: number;
|
||||
comment_edit_window_minutes: number;
|
||||
rate_limit_post: number;
|
||||
rate_limit_comment: number;
|
||||
rate_limit_register: number;
|
||||
rate_limit_login: number;
|
||||
rate_limit_window_sec: number;
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
signature_max: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
/** 伪静态(固定链接)开关 */
|
||||
permalink_enabled: boolean;
|
||||
/** 伪静态后缀,不含点,如 html / htm */
|
||||
permalink_ext: string;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
comment_edit_window_minutes: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
signature_max: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
permalink_enabled: boolean;
|
||||
permalink_ext: string;
|
||||
}
|
||||
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SiteBranding {
|
||||
name: string;
|
||||
slogan: string;
|
||||
/** 站点简介(首页可见 + SEO description) */
|
||||
description?: string;
|
||||
/** SEO keywords,逗号分隔 */
|
||||
keywords?: string;
|
||||
logo_mark: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
/** 默认社交分享图(Open Graph) */
|
||||
og_image?: string;
|
||||
/** ICP 备案号(可选) */
|
||||
icp_beian?: string;
|
||||
/** ICP 备案跳转链接(可选,默认工信部查询页) */
|
||||
icp_beian_url?: string;
|
||||
/** 页脚友情链接 */
|
||||
friend_links?: FriendLink[];
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
filter_path: string;
|
||||
data_dir: string;
|
||||
db_path: string;
|
||||
port: number;
|
||||
limits: ForumLimits;
|
||||
mail: MailConfig;
|
||||
oidc: OIDCConfig;
|
||||
oauth_clients: OAuthClient[];
|
||||
gitea?: GiteaSyncConfig;
|
||||
storage?: StorageConfig;
|
||||
branding?: SiteBranding;
|
||||
filter_words: string;
|
||||
filter_word_count: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: 'local' | 's3';
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
access_key: string;
|
||||
secret_key?: string;
|
||||
public_base_url: string;
|
||||
prefix: string;
|
||||
force_path_style: boolean;
|
||||
has_secret_key: boolean;
|
||||
ready: boolean;
|
||||
/** 展示方案:webp(默认)| original;上传始终保留原图 */
|
||||
image_delivery: 'webp' | 'original';
|
||||
}
|
||||
|
||||
export type MediaCategory = 'avatars' | 'posts' | 'site';
|
||||
|
||||
export interface MediaItem {
|
||||
category: MediaCategory;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
modified_at: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface MediaListResult {
|
||||
files: MediaItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
storage_type: 'local' | 's3';
|
||||
category_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MailConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
from: string;
|
||||
from_name: string;
|
||||
encryption: 'none' | 'starttls' | 'ssl';
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
enabled: boolean;
|
||||
root_url: string;
|
||||
ready: boolean;
|
||||
discovery_url?: string;
|
||||
authorize_url?: string;
|
||||
logout_url?: string;
|
||||
group_claim: string;
|
||||
admin_group: string;
|
||||
user_group: string;
|
||||
client_count: number;
|
||||
}
|
||||
|
||||
export interface OAuthClient {
|
||||
id: number;
|
||||
client_id: string;
|
||||
name: string;
|
||||
redirect_uris: string;
|
||||
enabled: boolean;
|
||||
has_secret: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
export interface OAuthClientInput {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
redirect_uris: string;
|
||||
enabled?: boolean;
|
||||
client_secret?: string;
|
||||
rotate_secret?: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaProject {
|
||||
id: number;
|
||||
gitea_id: number;
|
||||
owner_login: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
html_url: string;
|
||||
updated_at_remote?: string | null;
|
||||
forum_user_id?: number;
|
||||
synced_at: string;
|
||||
}
|
||||
|
||||
export interface GiteaSyncConfig {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
token?: string;
|
||||
has_token: boolean;
|
||||
sync_interval_min: number;
|
||||
ready: boolean;
|
||||
repo_count: number;
|
||||
}
|
||||
|
||||
export interface RegisterConfig {
|
||||
is_first_user: boolean;
|
||||
mail_ready: boolean;
|
||||
require_email_code: boolean;
|
||||
register_open: boolean;
|
||||
/** 邮箱验证码位数,默认 6 */
|
||||
email_code_len?: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
items: T;
|
||||
}
|
||||
|
||||
export interface RecentComment {
|
||||
id: number;
|
||||
post_id: number;
|
||||
floor: number;
|
||||
user_id?: number;
|
||||
author: string;
|
||||
avatar: string;
|
||||
excerpt: string;
|
||||
post_title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 站内私信 */
|
||||
export interface PrivateMessage {
|
||||
id: number;
|
||||
from_user_id: number;
|
||||
to_user_id: number;
|
||||
subject: string;
|
||||
content: string;
|
||||
kind: 'user' | 'system' | 'reject' | 'report_result' | string;
|
||||
related_post_id?: number;
|
||||
related_report_id?: number;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
from_user?: User;
|
||||
to_user?: User;
|
||||
}
|
||||
|
||||
/** 按对方聚合的私信会话 */
|
||||
export interface MessageConversation {
|
||||
peer_user_id: number; // 0 = 系统通知
|
||||
peer_user?: User;
|
||||
is_system: boolean;
|
||||
last_message?: PrivateMessage;
|
||||
unread_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
|
||||
export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
|
||||
|
||||
/** 帖子/评论举报(有 comment_id 时为评论举报) */
|
||||
export interface PostReport {
|
||||
id: number;
|
||||
post_id: number;
|
||||
comment_id?: number;
|
||||
reporter_id: number;
|
||||
reason: ReportReason | string;
|
||||
detail: string;
|
||||
status: ReportStatus | string;
|
||||
handler_id?: number;
|
||||
handle_note: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
post?: PostItem;
|
||||
comment?: Comment;
|
||||
reporter?: User;
|
||||
handler?: User;
|
||||
}
|
||||
|
||||
export interface BadgeDef {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
kind: 'auto' | 'limited' | string;
|
||||
metric: string;
|
||||
threshold: number;
|
||||
sort_order: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PointLedger {
|
||||
id: number;
|
||||
user_id: number;
|
||||
delta: number;
|
||||
balance: number;
|
||||
reason: string;
|
||||
ref_type: string;
|
||||
ref_id: number;
|
||||
note: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CheckInStatus {
|
||||
checked_in: boolean;
|
||||
streak: number;
|
||||
today_points: number;
|
||||
day: string;
|
||||
}
|
||||
|
||||
export interface LotteryStatus {
|
||||
drawn: boolean;
|
||||
points: number;
|
||||
day: string;
|
||||
cost: number;
|
||||
pool?: { points: number; weight: number }[];
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (typeof error.data === 'string' && error.data) return error.data;
|
||||
if (error.data && typeof error.data === 'object' && 'message' in error.data) {
|
||||
const m = (error.data as { message?: unknown }).message;
|
||||
if (typeof m === 'string' && m) return m;
|
||||
}
|
||||
return error.statusText || `错误 ${error.status}`;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error ?? '未知错误');
|
||||
}
|
||||
|
||||
/**
|
||||
* React Router 路由级错误页。
|
||||
* 发版后动态模块 404 时自动刷新;其它错误给出手动刷新入口。
|
||||
*/
|
||||
export default function AppRouteError() {
|
||||
const error = useRouteError();
|
||||
const chunkMiss = isChunkLoadError(error);
|
||||
|
||||
useEffect(() => {
|
||||
if (chunkMiss) reloadForStaleChunk();
|
||||
}, [chunkMiss]);
|
||||
|
||||
if (chunkMiss) {
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>…</div>
|
||||
<h1 className="error-page__title">正在更新页面</h1>
|
||||
<p className="error-page__desc">检测到程序已更新,正在自动刷新以加载最新版本…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>500</div>
|
||||
<h1 className="error-page__title">页面加载出错</h1>
|
||||
<p className="error-page__desc">{errorMessage(error)}</p>
|
||||
<div className="error-page__actions">
|
||||
<Button size="sm" onClick={() => window.location.reload()}>
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
import {
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection, NodeSelection } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { TableKit } from '@tiptap/extension-table';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||||
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
||||
MessageSquareLock, Coins,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import PostContent from './PostContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor, applyTextareaChange } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
prefixMarkdownLines,
|
||||
cycleMarkdownHeading,
|
||||
insertMarkdownMembersOnly,
|
||||
insertMarkdownReplyOnly,
|
||||
insertMarkdownLink,
|
||||
} from '../utils/markdownFormat';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
import { ReplyOnly } from './editor/ReplyOnlyExtension';
|
||||
import { PointsOnly } from './editor/PointsOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
||||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||||
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
|
||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
|
||||
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
|
||||
import {
|
||||
ArticleTableDialog,
|
||||
type TableInsertOptions,
|
||||
} from './editor/ArticleTableDialog';
|
||||
import {
|
||||
formatFenceInfo,
|
||||
type CodeBlockInsertOptions,
|
||||
} from '../utils/codeBlockOptions';
|
||||
import { fenceLengthForContent } from '../utils/markdownFences';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
isEmpty: () => boolean;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type EditorMode = 'rich' | 'markdown';
|
||||
type LinkTarget = 'rich' | 'markdown';
|
||||
type CodeBlockTarget = 'rich' | 'markdown';
|
||||
type TableTarget = 'rich' | 'markdown';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…';
|
||||
|
||||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
||||
const info = formatFenceInfo(opts);
|
||||
const fence = '`'.repeat(fenceLengthForContent(body));
|
||||
const open = info ? `${fence}${info}` : fence;
|
||||
return `\n${open}\n${body}\n${fence}\n`;
|
||||
}
|
||||
|
||||
/** 生成 GFM 管道表;源码侧始终带表头分隔行 */
|
||||
function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
|
||||
const cols = Math.max(1, opts.cols);
|
||||
const totalRows = Math.max(1, opts.rows);
|
||||
const bodyRows = Math.max(0, totalRows - 1);
|
||||
const headerCells = Array.from({ length: cols }, (_, i) => `列${i + 1}`);
|
||||
const sepCells = Array.from({ length: cols }, () => '---');
|
||||
const emptyCells = Array.from({ length: cols }, () => ' ');
|
||||
const lines = [
|
||||
`| ${headerCells.join(' | ')} |`,
|
||||
`| ${sepCells.join(' | ')} |`,
|
||||
...Array.from({ length: bodyRows }, () => `| ${emptyCells.join(' | ')} |`),
|
||||
];
|
||||
return `\n\n${lines.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空 */
|
||||
function isEditorEmpty(editor: Editor): boolean {
|
||||
return editor.state.doc.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将光标放到第一个文本块开头。
|
||||
* 文档以分割线等原子节点开头时,默认选区会变成 NodeSelection,出现整条高亮。
|
||||
*/
|
||||
function placeCaretInFirstTextblock(editor: Editor) {
|
||||
const { state } = editor;
|
||||
const { doc, selection } = state;
|
||||
let pos: number | null = null;
|
||||
doc.descendants((node, nodePos) => {
|
||||
if (node.isTextblock) {
|
||||
pos = nodePos + 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (pos == null) return;
|
||||
|
||||
const needsMove =
|
||||
selection instanceof NodeSelection
|
||||
|| selection.from !== pos
|
||||
|| selection.to !== pos;
|
||||
if (!needsMove) return;
|
||||
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.create(doc, pos))
|
||||
.setMeta('addToHistory', false);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||||
function cycleHeading(editor: Editor) {
|
||||
for (let level = 2; level <= 6; level += 1) {
|
||||
if (editor.isActive('heading', { level })) {
|
||||
if (level === 6) {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
} else {
|
||||
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}
|
||||
|
||||
/** 触发图片文件选择并上传(支持多选) */
|
||||
async function uploadPostImageFiles(multiple = true): Promise<string[]> {
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.multiple = multiple;
|
||||
input.onchange = async () => {
|
||||
const files = [...(input.files ?? [])];
|
||||
if (!files.length) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const urls: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
urls.push(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
}
|
||||
resolve(urls);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** 渲染工具栏按钮列表 */
|
||||
function renderToolButtons(tools: ToolBtn[]) {
|
||||
return tools.map((t, i) => (
|
||||
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
|
||||
{t.className === 'article-tool-btn--members' ? (
|
||||
<span className="article-editor-tools-sep" aria-hidden="true" />
|
||||
) : null}
|
||||
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
||||
ref,
|
||||
) {
|
||||
const isInternalUpdate = useRef(false);
|
||||
const lastValueRef = useRef(value);
|
||||
const [, setEditorTick] = useState(0);
|
||||
const [mode, setMode] = useState<EditorMode>('rich');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState('');
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [linkDialogUrl, setLinkDialogUrl] = useState('');
|
||||
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||||
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
|
||||
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
|
||||
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
|
||||
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
|
||||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||||
const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
|
||||
const [tableEditing, setTableEditing] = useState(false);
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
paragraph: false,
|
||||
codeBlock: false,
|
||||
// StarterKit v3 已内置;下面单独配置,需先关掉避免重复
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
ArticleCodeBlock,
|
||||
TableKit.configure({
|
||||
table: {
|
||||
resizable: false,
|
||||
HTMLAttributes: { class: 'article-table' },
|
||||
},
|
||||
}),
|
||||
ClearFloatParagraph,
|
||||
ClearFloatSync,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
ArticleImage.configure({ inline: false, allowBase64: false }),
|
||||
ImageGroup,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
return MEMBERS_ONLY_PLACEHOLDER;
|
||||
}
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
|
||||
return REPLY_ONLY_PLACEHOLDER;
|
||||
}
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') {
|
||||
return '此处内容需积分解锁后可见…';
|
||||
}
|
||||
return placeholder;
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
ReplyOnly,
|
||||
PointsOnly,
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
autofocus: false,
|
||||
onCreate: ({ editor: ed }) => {
|
||||
placeCaretInFirstTextblock(ed);
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const html = sanitizeHtml(ed.getHTML());
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
|
||||
// 清空后折叠选区,避免空文档残留全选高亮
|
||||
if (isEditorEmpty(ed) && !ed.state.selection.empty) {
|
||||
ed.commands.setTextSelection(1);
|
||||
}
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
onTransaction: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'article-prosemirror post-detail-content',
|
||||
spellcheck: 'false',
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||||
if (!view.state.doc.textContent.trim()) {
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, 1),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||||
useEffect(() => {
|
||||
if (!editor || mode !== 'rich') return;
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
const next = sanitizeHtml(value);
|
||||
if (next === lastValueRef.current) return;
|
||||
lastValueRef.current = next;
|
||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||
// 加载长文时避免首行分割线被 NodeSelection 选中
|
||||
placeCaretInFirstTextblock(editor);
|
||||
}, [value, editor, mode]);
|
||||
|
||||
// 全屏时锁定页面滚动,Esc 退出
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return undefined;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => {
|
||||
if (mode === 'markdown') {
|
||||
return sanitizeHtml(markdownToHtml(markdownSource));
|
||||
}
|
||||
return editor ? sanitizeHtml(editor.getHTML()) : value;
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (mode === 'markdown') {
|
||||
return markdownSource.trim().length === 0;
|
||||
}
|
||||
return editor ? isEditorEmpty(editor) : !value.trim();
|
||||
},
|
||||
focus: () => {
|
||||
if (mode === 'markdown') {
|
||||
markdownRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
placeCaretInFirstTextblock(editor);
|
||||
editor.commands.focus();
|
||||
},
|
||||
}), [editor, value, mode, markdownSource]);
|
||||
|
||||
const handleMarkdownChange = useCallback((next: string) => {
|
||||
setMarkdownSource(next);
|
||||
const html = sanitizeHtml(markdownToHtml(next));
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
}, [onChange]);
|
||||
|
||||
const openLinkDialog = useCallback((target: LinkTarget) => {
|
||||
if (target === 'rich') {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
setLinkDialogUrl(prev ?? '');
|
||||
} else {
|
||||
setLinkDialogUrl('');
|
||||
}
|
||||
setLinkTarget(target);
|
||||
setLinkDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
|
||||
setCodeBlockTarget(target);
|
||||
if (target === 'rich' && editor?.isActive('codeBlock')) {
|
||||
const attrs = editor.getAttributes('codeBlock');
|
||||
setCodeBlockEditing(true);
|
||||
setCodeBlockInitial({
|
||||
language: (attrs.language as string) || '',
|
||||
lineNumbers: Boolean(attrs.lineNumbers),
|
||||
collapsed: Boolean(attrs.collapsed),
|
||||
});
|
||||
} else {
|
||||
setCodeBlockEditing(false);
|
||||
setCodeBlockInitial(null);
|
||||
}
|
||||
setCodeBlockDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const applyCodeBlock = useCallback((opts: CodeBlockInsertOptions) => {
|
||||
if (codeBlockTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = markdownSource.slice(selectionStart, selectionEnd);
|
||||
const body = selected || '代码';
|
||||
const snippet = buildMarkdownCodeBlockSnippet(opts, body);
|
||||
const next = markdownSource.slice(0, selectionStart) + snippet + markdownSource.slice(selectionEnd);
|
||||
const bodyOffset = snippet.indexOf(body);
|
||||
const newStart = bodyOffset >= 0 ? selectionStart + bodyOffset : selectionStart + snippet.length;
|
||||
const newEnd = bodyOffset >= 0 ? newStart + body.length : newStart;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setArticleCodeBlock({
|
||||
language: opts.language || null,
|
||||
lineNumbers: opts.lineNumbers,
|
||||
collapsed: opts.collapsed,
|
||||
}).run();
|
||||
}, [codeBlockTarget, editor, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeCodeBlock = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('codeBlock')) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const openTableDialog = useCallback((target: TableTarget) => {
|
||||
setTableTarget(target);
|
||||
setTableEditing(target === 'rich' && Boolean(editor?.isActive('table')));
|
||||
setTableDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const applyTable = useCallback((opts: TableInsertOptions) => {
|
||||
if (tableTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
insertAtCursor(textarea, markdownSource, buildMarkdownTableSnippet(opts), handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertTable({
|
||||
rows: opts.rows,
|
||||
cols: opts.cols,
|
||||
withHeaderRow: opts.withHeaderRow,
|
||||
}).run();
|
||||
}, [tableTarget, editor, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeTable = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('table')) {
|
||||
editor.chain().focus().deleteTable().run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (linkTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea || !url) return;
|
||||
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}, [editor]);
|
||||
|
||||
const setImage = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
const urls = await uploadPostImageFiles(true);
|
||||
if (!urls.length) return;
|
||||
if (urls.length === 1) {
|
||||
editor.chain().focus().setImage({ src: urls[0] }).run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
|
||||
}, [editor]);
|
||||
|
||||
const setImageDisplay = useCallback((display: ImageDisplay) => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setImageDisplay(display).run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapSelectedAsGroup = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (!editor.commands.wrapImagesInGroup()) {
|
||||
notify.warning('请先点击或靠近至少两张连续图片,再合并为图组');
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('membersOnly')) {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertMembersOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapReplyOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('replyOnly')) {
|
||||
editor.chain().focus().exitReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertReplyOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapPointsOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('pointsOnly')) {
|
||||
editor.chain().focus().exitPointsOnly().run();
|
||||
return;
|
||||
}
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapPointsOnly(10).run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertPointsOnly(10).run();
|
||||
}, [editor]);
|
||||
|
||||
const switchToMarkdown = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const html = sanitizeHtml(editor.getHTML());
|
||||
lastValueRef.current = html;
|
||||
setMarkdownSource(htmlToMarkdown(html));
|
||||
setMode('markdown');
|
||||
}, [editor]);
|
||||
|
||||
const switchToRich = useCallback(() => {
|
||||
const html = sanitizeHtml(markdownToHtml(markdownSource));
|
||||
lastValueRef.current = html;
|
||||
isInternalUpdate.current = true;
|
||||
onChange(html);
|
||||
if (editor) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
placeCaretInFirstTextblock(editor);
|
||||
}
|
||||
setMode('rich');
|
||||
}, [editor, markdownSource, onChange]);
|
||||
|
||||
const withMarkdown = useCallback((fn: (
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
) => void) => () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
fn(textarea, markdownSource, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const insertMarkdownImage = useCallback(async () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const urls = await uploadPostImageFiles(true);
|
||||
if (!urls.length) return;
|
||||
if (urls.length === 1) {
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
const layout = suggestImageGroupLayout(urls.length);
|
||||
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
|
||||
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
|
||||
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => sanitizeHtml(markdownToHtml(markdownSource)),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
const imageActive = editor.isActive('image');
|
||||
const groupActive = editor.isActive('imageGroup');
|
||||
const currentDisplay = (editor.getAttributes('image').display as ImageDisplay) || 'default';
|
||||
|
||||
const tools: ToolBtn[] = [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
|
||||
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
|
||||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
|
||||
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') },
|
||||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入表格;表内可增删行列', active: editor.isActive('table'), action: () => openTableDialog('rich') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '上传图片',
|
||||
hint: '可多选;多张自动并排成图组',
|
||||
action: setImage,
|
||||
},
|
||||
{
|
||||
icon: <Columns2 size={15} />,
|
||||
title: '合并为图组',
|
||||
hint: '点击一张图后合并其附近连续图片(无需框选多张)',
|
||||
active: groupActive,
|
||||
action: wrapSelectedAsGroup,
|
||||
},
|
||||
];
|
||||
|
||||
if (editor.isActive('table')) {
|
||||
tools.push(
|
||||
{
|
||||
icon: <BetweenHorizonalStart size={15} />,
|
||||
title: '下方插入行',
|
||||
action: () => editor.chain().focus().addRowAfter().run(),
|
||||
},
|
||||
{
|
||||
icon: <BetweenVerticalStart size={15} />,
|
||||
title: '右侧插入列',
|
||||
action: () => editor.chain().focus().addColumnAfter().run(),
|
||||
},
|
||||
{
|
||||
icon: <Rows3 size={15} />,
|
||||
title: '删除行',
|
||||
hint: '删除当前行',
|
||||
action: () => editor.chain().focus().deleteRow().run(),
|
||||
},
|
||||
{
|
||||
icon: <Columns3 size={15} />,
|
||||
title: '删除列',
|
||||
hint: '删除当前列',
|
||||
action: () => editor.chain().focus().deleteColumn().run(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (imageActive && !groupActive) {
|
||||
tools.push(
|
||||
{
|
||||
icon: <StretchHorizontal size={15} />,
|
||||
title: '通栏大图',
|
||||
active: currentDisplay === 'wide',
|
||||
action: () => setImageDisplay(currentDisplay === 'wide' ? 'default' : 'wide'),
|
||||
},
|
||||
{
|
||||
icon: <PanelLeft size={15} />,
|
||||
title: '左绕排',
|
||||
hint: '图片居左,文字环绕',
|
||||
active: currentDisplay === 'float-left',
|
||||
action: () => setImageDisplay(currentDisplay === 'float-left' ? 'default' : 'float-left'),
|
||||
},
|
||||
{
|
||||
icon: <PanelRight size={15} />,
|
||||
title: '右绕排',
|
||||
hint: '图片居右,文字环绕',
|
||||
active: currentDisplay === 'float-right',
|
||||
action: () => setImageDisplay(currentDisplay === 'float-right' ? 'default' : 'float-right'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
tools.push(
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '读者回复后才可见;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('replyOnly'),
|
||||
className: 'article-tool-btn--reply',
|
||||
action: wrapReplyOnly,
|
||||
},
|
||||
{
|
||||
icon: <Coins size={15} />,
|
||||
title: '积分可见',
|
||||
hint: '读者花费积分解锁;可设价格',
|
||||
active: editor.isActive('pointsOnly'),
|
||||
className: 'article-tool-btn--points',
|
||||
action: wrapPointsOnly,
|
||||
},
|
||||
);
|
||||
|
||||
return tools;
|
||||
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
||||
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
||||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '插入 <reply-only> 区块',
|
||||
className: 'article-tool-btn--reply',
|
||||
action: withMarkdown(insertMarkdownReplyOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
? countWords(markdownSource)
|
||||
: (editor ? countWords(editor.getText()) : 0);
|
||||
|
||||
return (
|
||||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{renderToolButtons(tools)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-body">
|
||||
{mode === 'rich' ? (
|
||||
<div className="article-editor-pane article-editor-pane--rich">
|
||||
<div className="article-editor-scroll">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="article-editor-markdown">
|
||||
<div className="article-editor-pane article-editor-pane--source">
|
||||
<div className="article-editor-scroll">
|
||||
<textarea
|
||||
ref={markdownRef}
|
||||
className="article-editor-markdown-input"
|
||||
value={markdownSource}
|
||||
onChange={e => handleMarkdownChange(e.target.value)}
|
||||
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
|
||||
placeholder="在此编写 Markdown 源码…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="article-editor-markdown-preview">
|
||||
<div className="article-editor-markdown-preview-label">预览</div>
|
||||
<PostContent
|
||||
html={markdownPreviewHtml}
|
||||
isLoggedIn
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<div className="article-editor-status-meta">
|
||||
<span className="article-editor-wordcount">{words} 字</span>
|
||||
<span className="article-editor-status-sep" aria-hidden>·</span>
|
||||
<span className="article-editor-mode-label">
|
||||
{mode === 'rich' ? '所见即所得' : 'Markdown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status-actions">
|
||||
<Tooltip
|
||||
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
|
||||
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
|
||||
>
|
||||
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
|
||||
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={fullscreen ? '退出全屏' : '全屏编辑'}
|
||||
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-editor-view-btn${fullscreen ? ' active' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setFullscreen(v => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
<span>{fullscreen ? '退出' : '全屏'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArticleLinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
initialUrl={linkDialogUrl}
|
||||
onConfirm={applyLink}
|
||||
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
|
||||
/>
|
||||
<ArticleCodeBlockDialog
|
||||
open={codeBlockDialogOpen}
|
||||
onOpenChange={setCodeBlockDialogOpen}
|
||||
initial={codeBlockInitial}
|
||||
editing={codeBlockEditing}
|
||||
onConfirm={applyCodeBlock}
|
||||
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
|
||||
/>
|
||||
<ArticleTableDialog
|
||||
open={tableDialogOpen}
|
||||
onOpenChange={setTableDialogOpen}
|
||||
editing={tableEditing}
|
||||
onConfirm={applyTable}
|
||||
onRemove={tableEditing ? removeTable : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticleEditor;
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
headings: PostHeading[];
|
||||
/** 滚动容器;不传则用 viewport */
|
||||
scrollRoot?: HTMLElement | null;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 根据滚动位置取当前应高亮的标题 id */
|
||||
function resolveActiveHeadingId(
|
||||
headings: PostHeading[],
|
||||
root: HTMLElement | null,
|
||||
offsetPx = 28,
|
||||
): string {
|
||||
if (headings.length === 0) return '';
|
||||
|
||||
const rootTop = root ? root.getBoundingClientRect().top : 0;
|
||||
const marker = rootTop + offsetPx;
|
||||
|
||||
let current = headings[0].id;
|
||||
for (const h of headings) {
|
||||
const el = document.getElementById(h.id);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top <= marker) {
|
||||
current = h.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
|
||||
export default function ArticleOutline({
|
||||
headings,
|
||||
scrollRoot,
|
||||
title = '文章目录',
|
||||
className,
|
||||
}: Props) {
|
||||
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
|
||||
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
|
||||
const lockUntilRef = useRef(0);
|
||||
const lockIdRef = useRef('');
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const minLevel = useMemo(
|
||||
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
|
||||
[headings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(headings[0]?.id ?? '');
|
||||
lockUntilRef.current = 0;
|
||||
lockIdRef.current = '';
|
||||
}, [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return undefined;
|
||||
|
||||
const root: HTMLElement | Window = scrollRoot ?? window;
|
||||
|
||||
const syncActive = () => {
|
||||
if (Date.now() < lockUntilRef.current) {
|
||||
if (lockIdRef.current) setActiveId(lockIdRef.current);
|
||||
return;
|
||||
}
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(syncActive);
|
||||
};
|
||||
|
||||
syncActive();
|
||||
root.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
root.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [headings, scrollRoot]);
|
||||
|
||||
const jumpTo = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
|
||||
setActiveId(id);
|
||||
lockIdRef.current = id;
|
||||
lockUntilRef.current = Date.now() + 900;
|
||||
|
||||
const root = scrollRoot;
|
||||
if (root) {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
|
||||
window.setTimeout(() => {
|
||||
if (lockIdRef.current !== id) return;
|
||||
lockUntilRef.current = 0;
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
}, 920);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('article-outline', className)}>
|
||||
<div className="sidebar-section article-outline-head">
|
||||
<ListTree size={12} aria-hidden />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{headings.length === 0 ? (
|
||||
<p className="article-outline-empty">本文暂无标题结构</p>
|
||||
) : (
|
||||
<nav className="article-outline-nav" aria-label="文章目录">
|
||||
{headings.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'article-outline-item',
|
||||
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
|
||||
activeId === h.id && 'active',
|
||||
)}
|
||||
onClick={() => jumpTo(h.id)}
|
||||
title={h.text}
|
||||
>
|
||||
{h.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
/** 登录/注册懒加载占位:保持 auth 页氛围,避免整屏空白转圈 */
|
||||
export default function AuthPageFallback() {
|
||||
return (
|
||||
<div className="auth-page" aria-busy="true" aria-label="加载中">
|
||||
<div className="auth-box auth-box--loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState, type ComponentProps } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = ComponentProps<typeof Input>;
|
||||
|
||||
/** 带显示/隐藏切换的密码输入 */
|
||||
export default function AuthPasswordInput({ className, ...props }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="auth-password-field">
|
||||
<Input
|
||||
{...props}
|
||||
type={visible ? 'text' : 'password'}
|
||||
className={cn('auth-password-field__input', className)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-password-field__toggle"
|
||||
onClick={() => setVisible(v => !v)}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{visible ? <EyeOff size={16} aria-hidden /> : <Eye size={16} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Cropper, { type Area } from 'react-easy-crop';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { getCroppedAvatarFile, validateAvatarOutput } from '../utils/avatarCrop';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
fileName?: string;
|
||||
/** 裁剪后文件体积上限(MB) */
|
||||
maxMb: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (file: File) => void;
|
||||
}
|
||||
|
||||
export default function AvatarCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
fileName,
|
||||
maxMb,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
}, [open, imageSrc]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
|
||||
const sizeErr = validateAvatarOutput(file, maxMb);
|
||||
if (sizeErr) {
|
||||
notify.error(sizeErr);
|
||||
return;
|
||||
}
|
||||
onConfirm(file);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
notify.error('裁剪失败,请重试');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="avatar-crop-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>
|
||||
拖动图片调整位置,滚轮或滑块缩放。裁剪结果按原图格式保存(JPG→JPEG,PNG 保留透明);GIF 裁剪后变为静态 JPG。服务端会额外生成 WebP。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="avatar-crop-stage">
|
||||
{imageSrc ? (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-crop-loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="avatar-crop-zoom">
|
||||
<span className="avatar-crop-zoom-label">缩放</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={e => setZoom(Number(e.target.value))}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={confirming} onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={confirming} disabled={!imageSrc} onClick={handleConfirm}>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ArrowUp, MessageSquare } from 'lucide-react';
|
||||
|
||||
/** 滚动超过该距离后显示按钮 */
|
||||
const SHOW_THRESHOLD = 320;
|
||||
/** 路由切换后等待滚动容器挂载的最大重试次数 */
|
||||
const BIND_RETRY_MAX = 24;
|
||||
const BIND_RETRY_MS = 50;
|
||||
|
||||
/**
|
||||
* 定位当前真正滚动的容器。
|
||||
* 前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose
|
||||
* 后台:.admin-main
|
||||
*/
|
||||
function pickScrollEl(scope: ParentNode): HTMLElement | null {
|
||||
// 手机首页:板块条 + 排序栏随列表滚动,滚动根是 main
|
||||
if (scope instanceof HTMLElement && scope.classList.contains('main-content--feed-mobile-scroll')) {
|
||||
return scope;
|
||||
}
|
||||
const mobileFeed = scope.querySelector?.('.main-content--feed-mobile-scroll')
|
||||
?? document.querySelector('.main-content--feed-mobile-scroll');
|
||||
if (mobileFeed instanceof HTMLElement) return mobileFeed;
|
||||
|
||||
const list = scope.querySelector<HTMLElement>('.post-list-scroll');
|
||||
if (list) return list;
|
||||
const page = scope.querySelector<HTMLElement>('.page-wrap');
|
||||
if (page) return page;
|
||||
const compose = scope.querySelector<HTMLElement>('.main-content--compose');
|
||||
if (compose) return compose;
|
||||
return scope.querySelector<HTMLElement>('.admin-main');
|
||||
}
|
||||
|
||||
function findScrollScope(): ParentNode | null {
|
||||
return document.querySelector('.main-content--feed-mobile-scroll')
|
||||
?? document.querySelector('.main-content')
|
||||
?? document.querySelector('.admin-shell');
|
||||
}
|
||||
|
||||
export default function BackToTop() {
|
||||
const loc = useLocation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
const isPostDetail = /^\/post\/\d+/.test(loc.pathname);
|
||||
|
||||
const syncVisible = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
setVisible(!!el && el.scrollTop > SHOW_THRESHOLD);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let bound: HTMLElement | null = null;
|
||||
|
||||
const onScroll = () => {
|
||||
if (!cancelled) syncVisible();
|
||||
};
|
||||
|
||||
const unbind = () => {
|
||||
if (bound) {
|
||||
bound.removeEventListener('scroll', onScroll);
|
||||
bound = null;
|
||||
}
|
||||
scrollElRef.current = null;
|
||||
};
|
||||
|
||||
const bind = (el: HTMLElement) => {
|
||||
if (bound === el) {
|
||||
onScroll();
|
||||
return;
|
||||
}
|
||||
unbind();
|
||||
bound = el;
|
||||
scrollElRef.current = el;
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
};
|
||||
|
||||
const tryBind = () => {
|
||||
if (cancelled) return;
|
||||
const scope = findScrollScope();
|
||||
if (!scope) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const next = pickScrollEl(scope);
|
||||
if (next) {
|
||||
bind(next);
|
||||
const waitingList =
|
||||
!next.classList.contains('post-list-scroll') &&
|
||||
!!(scope as Element).querySelector?.('.feed-panel');
|
||||
if (waitingList && attempts < BIND_RETRY_MAX) {
|
||||
attempts += 1;
|
||||
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
unbind();
|
||||
setVisible(false);
|
||||
if (attempts < BIND_RETRY_MAX) {
|
||||
attempts += 1;
|
||||
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
tryBind();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(retryTimer);
|
||||
unbind();
|
||||
};
|
||||
}, [loc.pathname, loc.search, syncVisible]);
|
||||
|
||||
const scrollToTop = () => {
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const scrollToComments = () => {
|
||||
const section = document.querySelector<HTMLElement>('.comment-section');
|
||||
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`back-to-top-stack${visible ? ' back-to-top-stack--visible' : ''}`}>
|
||||
{isPostDetail && (
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top back-to-top--comment"
|
||||
onClick={scrollToComments}
|
||||
aria-label="前往评论"
|
||||
title="前往评论"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<MessageSquare size={18} strokeWidth={2.25} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top"
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<ArrowUp size={20} strokeWidth={2.25} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BOARD_ICON_OPTIONS,
|
||||
BOARD_PALETTE_SIZE,
|
||||
getBoardIcon,
|
||||
getBoardThemeIndex,
|
||||
} from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
board: Pick<Board, 'id' | 'color_index'>;
|
||||
}
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: number;
|
||||
onChange: (index: number) => void;
|
||||
boardId: number;
|
||||
}
|
||||
|
||||
/** 板块图标选择器 */
|
||||
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
|
||||
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
|
||||
const PreviewIcon = getBoardIcon(previewBoard);
|
||||
const themeIdx = getBoardThemeIndex(previewBoard);
|
||||
|
||||
return (
|
||||
<div className="board-appearance-picker">
|
||||
<div className="board-appearance-picker__preview">
|
||||
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<PreviewIcon aria-hidden />
|
||||
</span>
|
||||
<span className="board-appearance-picker__preview-hint">预览</span>
|
||||
</div>
|
||||
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn('board-icon-option', !value && 'board-icon-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<span className="board-icon-option__auto">A</span>
|
||||
</button>
|
||||
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === key}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 板块色标选择器(-1 为自动) */
|
||||
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
|
||||
return (
|
||||
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value < 0}
|
||||
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange(-1)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === i}
|
||||
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
|
||||
title={`色标 ${i + 1}`}
|
||||
onClick={() => onChange(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
board: Pick<Board, 'id' | 'name' | 'color_index'>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 按板块配置或 id 映射不同色标的 Badge */
|
||||
export default function BoardBadge({ board, className }: Props) {
|
||||
const idx = getBoardThemeIndex(board);
|
||||
return (
|
||||
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
|
||||
{board.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getBoardIcon } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
/** 渲染板块 Lucide 图标 */
|
||||
export default function BoardIconDisplay({
|
||||
board,
|
||||
className,
|
||||
}: {
|
||||
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = getBoardIcon(board);
|
||||
return <Icon className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { User, Comment } from '../api/types';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import { commentNick } from '../utils/comment';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
|
||||
export interface CommentSubmitData {
|
||||
content: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User | null;
|
||||
replyTo?: Comment | null;
|
||||
inline?: boolean;
|
||||
submitting?: boolean;
|
||||
submitCount?: number;
|
||||
onSubmit: (data: CommentSubmitData) => void;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
/** 评论输入框:需登录后发表 */
|
||||
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
|
||||
const [content, setContent] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const owoRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inline && replyTo) {
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [replyTo?.id, inline]);
|
||||
|
||||
useEffect(() => {
|
||||
setContent('');
|
||||
setShowEmoji(false);
|
||||
setIsPrivate(false);
|
||||
}, [submitCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEmoji) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||
setShowEmoji(false);
|
||||
owoRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowEmoji(false);
|
||||
owoRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [showEmoji]);
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const start = el.selectionStart ?? content.length;
|
||||
const end = el.selectionEnd ?? content.length;
|
||||
const next = content.slice(0, start) + emoji + content.slice(end);
|
||||
setContent(next);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = start + emoji.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else {
|
||||
setContent((prev) => prev + emoji);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!user) return;
|
||||
const text = content.trim();
|
||||
if (!text) {
|
||||
notify.warning('请先写点内容');
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onSubmit({ content: text, isPrivate });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className={`comment-login-gate${inline ? ' comment-login-gate--inline' : ''}`}>
|
||||
<p className="comment-login-gate__text">登录后即可参与讨论与回复</p>
|
||||
<div className="comment-login-gate__actions">
|
||||
<Button asChild size="sm">
|
||||
<Link to={loginPath()}>登录</Link>
|
||||
</Button>
|
||||
<Link to={registerPath()} className="comment-login-gate__register">
|
||||
注册账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarInitial = user.nickname?.[0] || '?';
|
||||
const canSend = !!content.trim() && !submitting;
|
||||
|
||||
return (
|
||||
<div className="comment-box" ref={boxRef}>
|
||||
<div className="comment-box-avatar">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="comment-box-avatar-placeholder">
|
||||
{avatarInitial}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="comment-box-main">
|
||||
{replyTo && !inline && (
|
||||
<div className="comment-box-reply-hint">
|
||||
<span>回复 #{replyTo.floor} {commentNick(replyTo)}</span>
|
||||
{onCancelReply && (
|
||||
<button type="button" className="comment-box-reply-cancel" onClick={onCancelReply}>取消</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="comment-box-textarea"
|
||||
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-box-send"
|
||||
disabled={!canSend}
|
||||
onClick={handleSubmit}
|
||||
aria-label="发送评论"
|
||||
title="发送(Ctrl/⌘ + Enter)"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="comment-box-toolbar">
|
||||
<button
|
||||
ref={owoRef}
|
||||
type="button"
|
||||
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
aria-label="插入表情"
|
||||
aria-expanded={showEmoji}
|
||||
aria-controls="comment-emoji-picker"
|
||||
>
|
||||
OwO
|
||||
</button>
|
||||
<label className="comment-box-private" title="仅作者与管理员可见">
|
||||
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
|
||||
<span>隐私评论</span>
|
||||
</label>
|
||||
<span className="comment-box-private-hint">仅作者与管理员可见</span>
|
||||
</div>
|
||||
|
||||
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { highlightMentions } from '../utils/content';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onMentionClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="floor-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { Comment, CommentRevision } from '../api/types';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { countLineChanges, diffTextLines } from '../utils/revisionDiff';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
comment: Comment | null;
|
||||
}
|
||||
|
||||
function DiffBlock({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextLines(before, after);
|
||||
const { added, removed } = countLineChanges(parts);
|
||||
if (before === after) {
|
||||
return <p className="revision-diff-unchanged">内容无变化</p>;
|
||||
}
|
||||
return (
|
||||
<div className="revision-diff-lines">
|
||||
<div className="revision-diff-stats">
|
||||
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del">删除 {removed} 行</span>}
|
||||
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add">新增 {added} 行</span>}
|
||||
</div>
|
||||
<pre className="revision-diff-pre">
|
||||
{parts.map((part, i) => {
|
||||
const lines = part.value.split('\n');
|
||||
return lines.map((line, j) => {
|
||||
if (j === lines.length - 1 && line === '') return null;
|
||||
const cls = part.added
|
||||
? 'revision-diff-line revision-diff-line--add'
|
||||
: part.removed
|
||||
? 'revision-diff-line revision-diff-line--del'
|
||||
: 'revision-diff-line revision-diff-line--same';
|
||||
const prefix = part.added ? '+' : part.removed ? '−' : ' ';
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={cls}>
|
||||
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
|
||||
<span className="revision-diff-text">{line || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 管理员查看评论编辑历史 */
|
||||
export default function CommentRevisionDialog({ open, onOpenChange, comment }: Props) {
|
||||
const [revisions, setRevisions] = useState<CommentRevision[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !comment) {
|
||||
setRevisions([]);
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.adminCommentRevisions(comment.id)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
const list = r.revisions ?? [];
|
||||
setRevisions(list);
|
||||
setActiveId(list[0]?.id ?? null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, comment]);
|
||||
|
||||
const active = revisions.find((r) => r.id === activeId) || null;
|
||||
const activeIndex = active ? revisions.findIndex((r) => r.id === active.id) : -1;
|
||||
const afterContent = activeIndex <= 0
|
||||
? (comment?.content ?? '')
|
||||
: (revisions[activeIndex - 1]?.content ?? '');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>评论编辑记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
{comment ? `#${comment.floor} 楼 · 共 ${revisions.length} 次修改前快照` : '评论编辑历史'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : revisions.length === 0 ? (
|
||||
<div className="admin-empty">暂无编辑记录</div>
|
||||
) : (
|
||||
<div className="comment-rev-layout">
|
||||
<aside className="comment-rev-list" aria-label="历史版本">
|
||||
{revisions.map((rev, i) => (
|
||||
<button
|
||||
key={rev.id}
|
||||
type="button"
|
||||
className={`comment-rev-item${activeId === rev.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveId(rev.id)}
|
||||
>
|
||||
<span className="comment-rev-item__ver">版本 {revisions.length - i}</span>
|
||||
<span className="comment-rev-item__meta">
|
||||
{rev.editor?.nickname || `用户 #${rev.editor_id}`}
|
||||
{' · '}
|
||||
{formatTime(rev.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<div className="comment-rev-detail">
|
||||
{active ? (
|
||||
<>
|
||||
<p className="comment-rev-detail__hint">
|
||||
与{activeIndex <= 0 ? '当前正文' : '下一版本'}对比
|
||||
</p>
|
||||
<DiffBlock before={active.content} after={afterContent} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Check, Clock, History, MessageSquare, X, Pencil, Trash2,
|
||||
ThumbsUp, MoreHorizontal, Flag,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment, ReportReason, User } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import CommentContent from './CommentContent';
|
||||
import CommentRevisionDialog from './CommentRevisionDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
formatCommentDate,
|
||||
isGuestComment,
|
||||
buildCommentTree,
|
||||
type CommentNode,
|
||||
} from '../utils/comment';
|
||||
import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
import UserLink from './UserLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function isCommentAuthor(c: Comment, user?: User | null): boolean {
|
||||
return !!user && c.user_id > 0 && c.user_id === user.id;
|
||||
}
|
||||
|
||||
function canEditComment(c: Comment, user: User | null | undefined, windowMinutes: number): boolean {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (!isCommentAuthor(c, user)) return false;
|
||||
if (windowMinutes <= 0) return true;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (Number.isNaN(created)) return false;
|
||||
return Date.now() - created <= windowMinutes * 60_000;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
node: CommentNode;
|
||||
nested?: boolean;
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
onStartEdit: (comment: Comment) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
onRequireLogin?: (actionLabel: string) => void;
|
||||
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */
|
||||
function CommentItem({
|
||||
node,
|
||||
nested,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
const c = node.comment;
|
||||
const nick = commentNick(c);
|
||||
const guest = isGuestComment(c);
|
||||
const isHighlighted = highlightFloor === c.floor;
|
||||
const hidden = !!c.content_hidden;
|
||||
const isReplying = replyToId === c.id;
|
||||
const isEditing = editingId === c.id;
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const editWindowMinutes = limits.comment_edit_window_minutes ?? 3;
|
||||
// 到期后强制重渲染,使「编辑」按钮自动消失
|
||||
const [, setEditExpireTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === 'admin') return;
|
||||
if (!isCommentAuthor(c, currentUser)) return;
|
||||
if (editWindowMinutes <= 0) return;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (Number.isNaN(created)) return;
|
||||
const remaining = created + editWindowMinutes * 60_000 - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
const timer = window.setTimeout(() => setEditExpireTick(n => n + 1), remaining + 30);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [c.created_at, c.id, c.user_id, currentUser, editWindowMinutes]);
|
||||
const canEdit = canEditComment(c, currentUser, editWindowMinutes);
|
||||
const canDelete = isAdmin;
|
||||
const canApprove = isAdmin
|
||||
&& (c.status === 'pending' || c.status === 'rejected')
|
||||
&& !!onApprove;
|
||||
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
|
||||
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
|
||||
const [editText, setEditText] = useState(c.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [liked, setLiked] = useState(!!c.liked);
|
||||
const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
|
||||
const [revOpen, setRevOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||
const [reportDetail, setReportDetail] = useState('');
|
||||
const [reporting, setReporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setEditText(c.content);
|
||||
}, [isEditing, c.content, c.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(!!c.liked);
|
||||
setLikeCount(c.like_count ?? 0);
|
||||
}, [c.id, c.liked, c.like_count]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const next = editText.trim();
|
||||
if (!next) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveEdit(c, next);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!currentUser) {
|
||||
onRequireLogin?.('点赞');
|
||||
return;
|
||||
}
|
||||
if (liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
const r = await api.likeComment(c.id);
|
||||
setLiked(r.liked);
|
||||
setLikeCount(r.like_count);
|
||||
onLikeUpdate?.(c.id, r.liked, r.like_count);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '点赞失败');
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openReport = () => {
|
||||
if (!currentUser) {
|
||||
onRequireLogin?.('举报');
|
||||
return;
|
||||
}
|
||||
setReportReason('spam');
|
||||
setReportDetail('');
|
||||
setReportOpen(true);
|
||||
};
|
||||
|
||||
const handleReport = async () => {
|
||||
setReporting(true);
|
||||
try {
|
||||
const r = await api.reportComment(c.id, {
|
||||
reason: reportReason,
|
||||
detail: reportDetail.trim() || undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setReportOpen(false);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '举报失败');
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`floor-${c.floor}`}
|
||||
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
|
||||
>
|
||||
{!guest && c.user_id ? (
|
||||
<UserLink
|
||||
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
className={`waline-comment-avatar user-link--avatar-only${!c.user?.avatar ? ' guest' : ''}`}
|
||||
>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</UserLink>
|
||||
) : (
|
||||
<div className={`waline-comment-avatar ${!c.user?.avatar ? 'guest' : ''}`}>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="waline-comment-main">
|
||||
<div className="waline-comment-head">
|
||||
{guest && c.guest_url ? (
|
||||
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
|
||||
{nick}
|
||||
</a>
|
||||
) : !guest && c.user_id ? (
|
||||
<UserLink
|
||||
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||
className="waline-comment-author"
|
||||
showBadges
|
||||
/>
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
)}
|
||||
{!hidden && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('waline-comment-like', liked && 'is-liked')}
|
||||
disabled={liking}
|
||||
aria-label={liked ? '取消点赞' : '点赞'}
|
||||
aria-pressed={liked}
|
||||
onClick={handleLike}
|
||||
>
|
||||
<ThumbsUp size={14} strokeWidth={2} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hidden ? (
|
||||
<div className="waline-comment-private-mask">
|
||||
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||
</div>
|
||||
) : isEditing ? (
|
||||
<div className="waline-comment-edit">
|
||||
<textarea
|
||||
className="waline-comment-edit-input"
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
|
||||
/>
|
||||
<div className="waline-comment-edit-actions">
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !editText.trim()}
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="waline-comment-bubble">
|
||||
<CommentContent content={c.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="waline-comment-meta">
|
||||
<span className="waline-comment-date">
|
||||
<Clock size={14} />
|
||||
{formatCommentDate(c.created_at)}
|
||||
{isAdmin && showEdited && <span className="waline-comment-edited"> · 已编辑</span>}
|
||||
{c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · 审核中</span>}
|
||||
{c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · 未通过</span>}
|
||||
</span>
|
||||
{c.reply_target && (
|
||||
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
|
||||
)}
|
||||
{!hidden && !isEditing && canApprove && (
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn waline-comment-approve-btn"
|
||||
disabled={approving}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await onApprove?.(c);
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check size={14} />
|
||||
{approving ? '通过中…' : '通过'}
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && (
|
||||
isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip content={`回复给 ${nick}`} side="top">
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||
<MessageSquare size={14} />
|
||||
回复
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{!hidden && !isEditing && canEdit && (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && isAdmin && showEdited && (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
|
||||
<History size={14} />
|
||||
编辑记录
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && canDelete && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(c);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{canReport && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn waline-comment-more"
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="report-more-menu">
|
||||
<DropdownMenuItem
|
||||
className="report-more-menu__item"
|
||||
onSelect={openReport}
|
||||
>
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
|
||||
)}
|
||||
|
||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>举报评论</DialogTitle>
|
||||
<DialogDescription>请选择原因,管理员将尽快处理。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>举报原因</span>
|
||||
<select
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value as ReportReason)}
|
||||
>
|
||||
{REPORT_REASON_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pm-field">
|
||||
<span>补充说明(可选)</span>
|
||||
<textarea
|
||||
value={reportDetail}
|
||||
onChange={(e) => setReportDetail(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder="补充更多细节…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReportOpen(false)}>取消</Button>
|
||||
<Button loading={reporting} onClick={handleReport}>提交举报</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isReplying && renderReplyBox && (
|
||||
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||
{renderReplyBox(c)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.children.length > 0 && (
|
||||
<div className="waline-replies">
|
||||
{node.children.map((child) => (
|
||||
<CommentItem
|
||||
key={child.comment.id}
|
||||
node={child}
|
||||
nested
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comments: Comment[];
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
onStartEdit: (comment: Comment) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
onRequireLogin?: (actionLabel: string) => void;
|
||||
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
/** Waline 嵌套楼层评论列表 */
|
||||
export default function CommentThreadList({
|
||||
comments,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
|
||||
return (
|
||||
<div className="comment-thread-list">
|
||||
{tree.map((node) => (
|
||||
<CommentItem
|
||||
key={node.comment.id}
|
||||
node={node}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
toUserId: number;
|
||||
toNickname: string;
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
/** 发送私信对话框(对话式,无需标题) */
|
||||
export default function ComposeMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
toUserId,
|
||||
toNickname,
|
||||
onSent,
|
||||
}: Props) {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) setContent('');
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!content.trim()) {
|
||||
notify.warning('请填写内容');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await api.sendMessage({
|
||||
to_user_id: toUserId,
|
||||
content: content.trim(),
|
||||
});
|
||||
notify.success('私信已发送');
|
||||
handleOpenChange(false);
|
||||
onSent?.();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送私信</DialogTitle>
|
||||
<DialogDescription>发给 {toNickname}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span className="sr-only">内容</span>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={6}
|
||||
maxLength={4000}
|
||||
placeholder="写点什么…"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>取消</Button>
|
||||
<Button loading={sending} onClick={submit}>发送</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { EMOJI_LIST } from '../utils/emojis';
|
||||
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** OwO 表情选择面板(方向键浏览,Enter 选中) */
|
||||
export default function EmojiPicker({ onSelect, id }: Props) {
|
||||
const autoId = useId();
|
||||
const listId = id ?? autoId;
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[active]?.focus();
|
||||
}, [active]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const cols = 8;
|
||||
let next = active;
|
||||
if (e.key === 'ArrowRight') next = Math.min(EMOJI_LIST.length - 1, active + 1);
|
||||
else if (e.key === 'ArrowLeft') next = Math.max(0, active - 1);
|
||||
else if (e.key === 'ArrowDown') next = Math.min(EMOJI_LIST.length - 1, active + cols);
|
||||
else if (e.key === 'ArrowUp') next = Math.max(0, active - cols);
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = EMOJI_LIST.length - 1;
|
||||
else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(EMOJI_LIST[active]);
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={listId}
|
||||
ref={listRef}
|
||||
className="emoji-picker"
|
||||
role="listbox"
|
||||
aria-label="表情列表"
|
||||
aria-activedescendant={`${listId}-opt-${active}`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{EMOJI_LIST.map((e, i) => (
|
||||
<button
|
||||
key={e}
|
||||
id={`${listId}-opt-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
tabIndex={active === i ? 0 : -1}
|
||||
aria-selected={active === i}
|
||||
className={`emoji-picker-item${active === i ? ' emoji-picker-item--active' : ''}`}
|
||||
aria-label={e}
|
||||
onClick={() => onSelect(e)}
|
||||
onFocus={() => setActive(i)}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { error: Error | null; reloading: boolean }
|
||||
|
||||
/** 捕获渲染异常;发版 chunk 失效时自动刷新 */
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null, reloading: false };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
if (isChunkLoadError(error) && reloadForStaleChunk()) {
|
||||
return { error, reloading: true };
|
||||
}
|
||||
return { error, reloading: false };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[Jiang13Forum]', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.reloading) {
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>…</div>
|
||||
<h1 className="error-page__title">正在更新页面</h1>
|
||||
<p className="error-page__desc">检测到程序已更新,正在自动刷新以加载最新版本…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>500</div>
|
||||
<h1 className="error-page__title">页面渲染出错</h1>
|
||||
<p className="error-page__desc">{this.state.error.message || '发生了意外错误,请尝试刷新页面。'}</p>
|
||||
<div className="error-page__actions">
|
||||
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 精华帖标识 */
|
||||
export default function FeaturedIcon({ className, size = 16 }: Props) {
|
||||
return (
|
||||
<Sparkles
|
||||
className={cn('post-featured-icon', className)}
|
||||
size={size}
|
||||
aria-label="精华"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Users, FileText, LayoutGrid } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
boardId: number;
|
||||
keyword: string;
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
postTotal: number;
|
||||
/** 搜索页用 h1;首页/板块页中间栏不再展示标题 */
|
||||
titleAs?: 'h1' | 'h2';
|
||||
}
|
||||
|
||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
|
||||
const nav = useNavigate();
|
||||
const board = boards.find(b => b.id === boardId);
|
||||
|
||||
const inBoard = !keyword && boardId > 0 && !!board;
|
||||
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */
|
||||
const title = keyword ? `搜索:${keyword}` : '';
|
||||
const TitleTag = titleAs;
|
||||
|
||||
return (
|
||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
||||
<div className="feed-head__title">
|
||||
{title ? <TitleTag>{title}</TitleTag> : null}
|
||||
{!keyword && inBoard && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
本板块 <strong>{postTotal}</strong> 帖
|
||||
</span>
|
||||
{stats && (
|
||||
<span className="feed-stat-chip feed-stat-chip--muted" title="全站统计">
|
||||
全站 {stats.posts} 帖 · {stats.users} 会员
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!keyword && !inBoard && stats && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<Users aria-hidden />
|
||||
会员 <strong>{stats.users}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
帖子 <strong>{stats.posts}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<LayoutGrid aria-hidden />
|
||||
板块 <strong>{stats.boards}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="feed-head__clear"
|
||||
onClick={() => nav('/')}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
)}
|
||||
{keyword && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
|
||||
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
|
||||
export default function FeedPageSkeleton() {
|
||||
return (
|
||||
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<div className="feed-top__bar">
|
||||
<div className="feed-head feed-head--stats-only">
|
||||
<div className="feed-head__title">
|
||||
<div className="feed-head__stats">
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<span className="feed-toolbar__spacer" />
|
||||
<Skeleton className="skeleton--count" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-list-scroll">
|
||||
<PostListSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
postTotal: number;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** 生成页码窗口:两端 + 当前邻页,中间用省略号 */
|
||||
function buildPageItems(current: number, total: number): Array<number | 'gap'> {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const set = new Set<number>();
|
||||
set.add(1);
|
||||
set.add(total);
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
if (i >= 1 && i <= total) set.add(i);
|
||||
}
|
||||
// 靠近端点时多露出几页,避免 1 … 2 3 这种浪费
|
||||
if (current <= 3) {
|
||||
set.add(2);
|
||||
set.add(3);
|
||||
set.add(4);
|
||||
}
|
||||
if (current >= total - 2) {
|
||||
set.add(total - 1);
|
||||
set.add(total - 2);
|
||||
set.add(total - 3);
|
||||
}
|
||||
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const items: Array<number | 'gap'> = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) items.push('gap');
|
||||
items.push(sorted[i]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function FeedPagination({
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
}: Props) {
|
||||
const [jumpInput, setJumpInput] = useState(String(page));
|
||||
const pageItems = buildPageItems(page, totalPages);
|
||||
const showJump = totalPages > 5;
|
||||
|
||||
useEffect(() => {
|
||||
setJumpInput(String(page));
|
||||
}, [page]);
|
||||
|
||||
const commitJump = () => {
|
||||
if (loading) return;
|
||||
const n = Number.parseInt(jumpInput, 10);
|
||||
if (!Number.isFinite(n)) {
|
||||
setJumpInput(String(page));
|
||||
return;
|
||||
}
|
||||
const target = Math.min(totalPages, Math.max(1, n));
|
||||
setJumpInput(String(target));
|
||||
if (target !== page) onPageChange(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="feed-pagination" aria-label="帖子分页">
|
||||
<p className="feed-pagination__meta" aria-live="polite">
|
||||
共 <strong>{postTotal}</strong> 条
|
||||
</p>
|
||||
|
||||
<div className="feed-pagination__pages">
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{pageItems.map((item, idx) =>
|
||||
item === 'gap' ? (
|
||||
<span key={`gap-${idx}`} className="feed-pagination__gap" aria-hidden>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={cn('feed-pagination__page', item === page && 'is-active')}
|
||||
disabled={loading || item === page}
|
||||
aria-label={`第 ${item} 页`}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showJump && (
|
||||
<form
|
||||
className="feed-pagination__jump"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
commitJump();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="feed-page-jump" className="feed-pagination__jump-label">
|
||||
跳至
|
||||
</label>
|
||||
<input
|
||||
id="feed-page-jump"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={jumpInput}
|
||||
disabled={loading}
|
||||
onChange={(e) => setJumpInput(e.target.value.replace(/\D/g, ''))}
|
||||
onBlur={commitJump}
|
||||
className="feed-pagination__jump-input"
|
||||
aria-label={`跳转到指定页,共 ${totalPages} 页`}
|
||||
/>
|
||||
<span className="feed-pagination__jump-suffix">/ {totalPages}</span>
|
||||
</form>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { Clock, MessageCircle, Flame } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
||||
|
||||
export type FeedSort = 'latest' | 'reply' | 'hot';
|
||||
|
||||
const SORT_OPTIONS: {
|
||||
key: FeedSort;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Clock;
|
||||
}[] = [
|
||||
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
|
||||
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
|
||||
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: FeedSort;
|
||||
onChange: (sort: FeedSort) => void;
|
||||
postTotal?: number;
|
||||
}
|
||||
|
||||
export function parseFeedSort(raw: string | null): FeedSort {
|
||||
if (raw === 'reply' || raw === 'hot') return raw;
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
|
||||
const p = new URLSearchParams();
|
||||
if (boardId) p.set('board', String(boardId));
|
||||
if (sort !== 'latest') p.set('sort', sort);
|
||||
const qs = p.toString();
|
||||
return qs ? `/?${qs}` : '/';
|
||||
}
|
||||
|
||||
export function feedSortLabel(sort: FeedSort): string {
|
||||
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
|
||||
}
|
||||
|
||||
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
|
||||
if (next == null) return;
|
||||
e.preventDefault();
|
||||
onChange(SORT_OPTIONS[next].key);
|
||||
requestAnimationFrame(() => {
|
||||
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
tabs?.[next]?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="feed-toolbar">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="feed-sort-bar"
|
||||
role="tablist"
|
||||
aria-label="帖子排序"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={activeIndex === i ? 0 : -1}
|
||||
aria-selected={value === key}
|
||||
title={`${label} · ${hint}`}
|
||||
className={cn('feed-sort-tab', value === key && 'active')}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
<span className="feed-sort-tab__label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{postTotal != null && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 帖子正文图片灯箱:展示原图,点击遮罩 / Esc / 关闭按钮退出 */
|
||||
export default function ImageLightbox({ src, alt = '', open, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !src) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="查看原图">
|
||||
<button
|
||||
type="button"
|
||||
className="image-lightbox-backdrop"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="image-lightbox-close"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={20} aria-hidden />
|
||||
</button>
|
||||
<div className="image-lightbox-stage">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || '原图'}
|
||||
className="image-lightbox-img"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p className="image-lightbox-hint">点击空白处关闭</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PageLoaderProps = {
|
||||
/** 独立全屏路由(登录/注册)占满视口居中 */
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
||||
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('page-loader', fullScreen && 'page-loader--viewport')}
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Coins, Dices, Gift } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { CheckInStatus, LotteryStatus, PointLedger } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const REASON_LABEL: Record<string, string> = {
|
||||
check_in: '签到',
|
||||
lottery: '抽奖',
|
||||
unlock_spend: '解锁内容',
|
||||
creator_income: '创作分成',
|
||||
admin_adjust: '站长调账',
|
||||
};
|
||||
|
||||
/** 个人中心:积分余额、签到、抽奖、流水 */
|
||||
export default function PointsWalletPanel() {
|
||||
const { refresh } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [points, setPoints] = useState(0);
|
||||
const [income, setIncome] = useState(0);
|
||||
const [checkIn, setCheckIn] = useState<CheckInStatus | null>(null);
|
||||
const [lottery, setLottery] = useState<LotteryStatus | null>(null);
|
||||
const [ledger, setLedger] = useState<PointLedger[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.mePoints(1)
|
||||
.then(d => {
|
||||
setPoints(d.points);
|
||||
setIncome(d.creator_income_total);
|
||||
setCheckIn(d.check_in);
|
||||
setLottery(d.lottery);
|
||||
setLedger(d.ledger ?? []);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const doCheckIn = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.checkIn();
|
||||
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
|
||||
setPoints(r.points);
|
||||
setCheckIn(r.check_in);
|
||||
await refresh();
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '签到失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doLottery = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.lotteryDraw();
|
||||
notify.success(r.lottery.points > 0 ? `抽中 ${r.lottery.points} 积分` : '未中奖,明天再来');
|
||||
setPoints(r.points);
|
||||
setLottery(r.lottery);
|
||||
await refresh();
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '抽奖失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="points-wallet">
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="points-wallet">
|
||||
<div className="points-wallet-head">
|
||||
<h3>
|
||||
<Coins size={18} aria-hidden />
|
||||
积分钱包
|
||||
</h3>
|
||||
<div className="points-wallet-balance">
|
||||
<strong>{points}</strong>
|
||||
<span>可用积分</span>
|
||||
<em title="累计创作分成">创作收入 {income}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="points-wallet-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !!checkIn?.checked_in}
|
||||
onClick={doCheckIn}
|
||||
>
|
||||
<Gift size={14} />
|
||||
{checkIn?.checked_in
|
||||
? `已签到(连续 ${checkIn.streak} 天)`
|
||||
: `签到 +${checkIn?.today_points ?? 5}`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !!lottery?.drawn}
|
||||
onClick={doLottery}
|
||||
>
|
||||
<Dices size={14} />
|
||||
{lottery?.drawn ? `今日已抽(${lottery.points})` : '每日抽奖'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="points-wallet-ledger">
|
||||
<h4>最近流水</h4>
|
||||
{ledger.length === 0 && <p className="points-wallet-empty">暂无流水</p>}
|
||||
<ul>
|
||||
{ledger.map(row => (
|
||||
<li key={row.id}>
|
||||
<span className={row.delta >= 0 ? 'pos' : 'neg'}>
|
||||
{row.delta >= 0 ? '+' : ''}{row.delta}
|
||||
</span>
|
||||
<span>{REASON_LABEL[row.reason] || row.reason}</span>
|
||||
<time>{new Date(row.created_at).toLocaleString('zh-CN')}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '../api/client';
|
||||
import type { User, UserActivityStats, UserPublic } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import ComposeMessageDialog from './ComposeMessageDialog';
|
||||
import UserLink from './UserLink';
|
||||
|
||||
interface Props {
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
}
|
||||
|
||||
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
|
||||
export default function PostAuthorCard({
|
||||
author,
|
||||
publishedAt,
|
||||
viewCount,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user: me } = useAuth();
|
||||
const [profile, setProfile] = useState<UserPublic | null>(null);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
||||
const [msgOpen, setMsgOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!author?.id) {
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.userProfile(author.id)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setProfile(r.user);
|
||||
setStats(r.stats);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// 详情里已有轻量 user,接口失败时仍可展示基本信息
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [author?.id]);
|
||||
|
||||
if (!author?.id) {
|
||||
return (
|
||||
<div className="widget-card widget-card--author">
|
||||
<div className="widget-card-head">
|
||||
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
|
||||
作者
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
<div className="widget-empty">作者信息加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const display = profile ?? author;
|
||||
const nick = display.nickname || display.username || `用户 #${author.id}`;
|
||||
const initial = nick.charAt(0) || '?';
|
||||
const signature = (profile?.signature ?? author.signature ?? '').trim();
|
||||
const isSelf = !!me && me.id === author.id;
|
||||
const profileHref = userPath(author.id);
|
||||
|
||||
const openMessage = () => {
|
||||
if (!me) {
|
||||
nav(loginPath(profileHref));
|
||||
return;
|
||||
}
|
||||
setMsgOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="widget-card widget-card--author">
|
||||
<div className="widget-card-head">
|
||||
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
|
||||
作者
|
||||
</div>
|
||||
<div className="widget-author-panel">
|
||||
<div className="widget-author-body">
|
||||
<UserLink
|
||||
user={display}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
className="widget-author-avatar user-link--avatar-only"
|
||||
>
|
||||
{display.avatar
|
||||
? <img src={display.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</UserLink>
|
||||
<div className="widget-author-meta">
|
||||
<div className="widget-author-name-row">
|
||||
<UserLink user={display} className="widget-author-name" showBadges />
|
||||
{display.banned && <Badge variant="destructive" className="widget-author-badge">已禁言</Badge>}
|
||||
</div>
|
||||
{signature ? (
|
||||
<p className="widget-author-signature" title={signature}>{signature}</p>
|
||||
) : null}
|
||||
{(publishedAt || typeof viewCount === 'number') && (
|
||||
<p className="widget-author-stats">
|
||||
{publishedAt ? <span>{formatTime(publishedAt)} 发布</span> : null}
|
||||
{publishedAt && typeof viewCount === 'number' ? (
|
||||
<span className="widget-author-stats-dot" aria-hidden>·</span>
|
||||
) : null}
|
||||
{typeof viewCount === 'number' ? (
|
||||
<span className="widget-author-views">
|
||||
<Eye size={12} aria-hidden />
|
||||
{viewCount}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-author-metrics" aria-label="作者统计">
|
||||
<div className="widget-author-metric">
|
||||
<FileText size={13} aria-hidden />
|
||||
<strong>{stats?.post_count ?? '—'}</strong>
|
||||
<span>帖子</span>
|
||||
</div>
|
||||
<div className="widget-author-metric">
|
||||
<MessageCircle size={13} aria-hidden />
|
||||
<strong>{stats?.comment_count ?? '—'}</strong>
|
||||
<span>评论</span>
|
||||
</div>
|
||||
<div className="widget-author-metric">
|
||||
<Heart size={13} aria-hidden />
|
||||
<strong>{stats?.like_received ?? '—'}</strong>
|
||||
<span>获赞</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-author-actions">
|
||||
{!isSelf && (
|
||||
<Button size="sm" className="widget-author-action" onClick={openMessage}>
|
||||
<Mail size={14} />
|
||||
私信
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="widget-author-action"
|
||||
onClick={() => nav(isSelf ? '/profile' : profileHref)}
|
||||
>
|
||||
{isSelf ? '我的主页' : '查看主页'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<ComposeMessageDialog
|
||||
open={msgOpen}
|
||||
onOpenChange={setMsgOpen}
|
||||
toUserId={author.id}
|
||||
toNickname={nick}
|
||||
onSent={() => nav(`/messages?peer=${author.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useMemo, useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
isLoggedIn: boolean;
|
||||
className?: string;
|
||||
/** 正文标题树变化时回调(用于侧栏目录) */
|
||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||
/** 点击「回复可见」门控的「去回复」 */
|
||||
onRequestReply?: () => void;
|
||||
/** 积分解锁成功后刷新正文 */
|
||||
onUnlocked?: () => void;
|
||||
postId?: number;
|
||||
}
|
||||
|
||||
/** 帖子正文渲染(含会员专属 / 回复可见 / 积分可见、代码块美化、图片灯箱) */
|
||||
export default function PostContent({
|
||||
html,
|
||||
isLoggedIn,
|
||||
className = 'post-detail-content',
|
||||
onHeadingsChange,
|
||||
onRequestReply,
|
||||
onUnlocked,
|
||||
postId: postIdProp,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const params = useParams();
|
||||
const postId = postIdProp || Number(params.id) || 0;
|
||||
const { limits } = useForumLimits();
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const [lightboxAlt, setLightboxAlt] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
const prepared = useMemo(() => {
|
||||
const rendered = renderPostContentHtml(html, isLoggedIn, {
|
||||
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
||||
});
|
||||
return {
|
||||
html: rendered,
|
||||
headings: extractHeadingsFromHtml(rendered),
|
||||
};
|
||||
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
|
||||
|
||||
useEffect(() => {
|
||||
onHeadingsChange?.(prepared.headings);
|
||||
}, [prepared.headings, onHeadingsChange]);
|
||||
|
||||
const openLightbox = useCallback((img: HTMLImageElement) => {
|
||||
const full = img.getAttribute('data-full') || img.currentSrc || img.src;
|
||||
if (!full) return;
|
||||
setLightboxSrc(full);
|
||||
setLightboxAlt(img.getAttribute('alt') || '');
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-reply-scroll]')) {
|
||||
e.preventDefault();
|
||||
onRequestReply?.();
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-login]')) {
|
||||
e.preventDefault();
|
||||
nav(loginPath());
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-register]')) {
|
||||
e.preventDefault();
|
||||
nav(registerPath());
|
||||
return;
|
||||
}
|
||||
const unlockBtn = target.closest<HTMLElement>('[data-points-unlock]');
|
||||
if (unlockBtn) {
|
||||
e.preventDefault();
|
||||
const blockKey = unlockBtn.getAttribute('data-block-key') || '';
|
||||
const cost = unlockBtn.getAttribute('data-cost') || '';
|
||||
if (!postId || !blockKey || unlocking) return;
|
||||
if (!window.confirm(`确认花费 ${cost} 积分解锁该内容?`)) return;
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await api.unlockPostBlock(postId, blockKey);
|
||||
notify.success('解锁成功');
|
||||
onUnlocked?.();
|
||||
} catch (err: unknown) {
|
||||
notify.error(err instanceof Error ? err.message : '解锁失败');
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const zoomImg = target.closest<HTMLImageElement>('img.post-content-img--zoomable');
|
||||
if (zoomImg) {
|
||||
e.preventDefault();
|
||||
openLightbox(zoomImg);
|
||||
return;
|
||||
}
|
||||
const headingCopy = target.closest<HTMLElement>('[data-heading-copy]');
|
||||
if (headingCopy) {
|
||||
e.preventDefault();
|
||||
const id = headingCopy.getAttribute('data-heading-copy') || '';
|
||||
if (!id) return;
|
||||
const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
notify.success('已复制本节链接');
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
|
||||
if (foldBtn) {
|
||||
e.preventDefault();
|
||||
const block = foldBtn.closest('.md-codeblock');
|
||||
if (!block) return;
|
||||
const collapsed = block.classList.toggle('md-codeblock--collapsed');
|
||||
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|
||||
|| block.querySelectorAll('.md-code-line').length
|
||||
|| 1;
|
||||
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
|
||||
else block.classList.remove('md-codeblock--short');
|
||||
foldBtn.textContent = collapsed ? '展开' : '收起';
|
||||
return;
|
||||
}
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
const block = copyBtn.closest('.md-codeblock');
|
||||
const bodies = block?.querySelectorAll('.md-code-line__body');
|
||||
const text = bodies && bodies.length
|
||||
? [...bodies].map(el => el.textContent ?? '').join('\n')
|
||||
: (block?.querySelector('pre')?.textContent ?? '');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
}
|
||||
}, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const zoomImg = (e.target as HTMLElement).closest?.('img.post-content-img--zoomable');
|
||||
if (!zoomImg || !(zoomImg instanceof HTMLImageElement)) return;
|
||||
e.preventDefault();
|
||||
openLightbox(zoomImg);
|
||||
}, [openLightbox]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||
/>
|
||||
<ImageLightbox
|
||||
src={lightboxSrc}
|
||||
alt={lightboxAlt}
|
||||
open={!!lightboxSrc}
|
||||
onClose={() => setLightboxSrc(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import UserLink from '@/components/UserLink';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
sort?: FeedSort;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
const initial = post.user?.nickname?.[0] || '?';
|
||||
const timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
? `${formatTime(post.last_reply_at)} 回复`
|
||||
: '暂无回复')
|
||||
: formatTime(post.created_at);
|
||||
const commentCount = post.comment_count ?? 0;
|
||||
const likeCount = post.like_count ?? 0;
|
||||
const viewCount = post.view_count ?? 0;
|
||||
const href = postPath(post.id);
|
||||
const excerpt = excerptFromHTML(post.content || '', 72);
|
||||
const hasImage = !!firstImageFromHTML(post.content || '');
|
||||
|
||||
const openPost = () => onSelect(post.id);
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openPost();
|
||||
}
|
||||
};
|
||||
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// 修饰键 / 非左键:交给浏览器(新标签等)
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openPost();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="post-row"
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={openPost}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<UserLink
|
||||
user={post.user}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="post-avatar user-link--avatar-only"
|
||||
>
|
||||
{post.user?.avatar
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</UserLink>
|
||||
|
||||
<div className="post-body">
|
||||
<div className="post-head">
|
||||
<div className="post-head-meta">
|
||||
<UserLink user={post.user} stopPropagation className="post-author" showBadges />
|
||||
<span className="post-head-dot" aria-hidden>·</span>
|
||||
<span className="post-time">{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-title-row">
|
||||
{post.pinned && (
|
||||
<span className="post-pin-badge" title="置顶">置顶</span>
|
||||
)}
|
||||
{post.featured && (
|
||||
<span className="post-feature-badge" title="精华">
|
||||
<FeaturedIcon size={12} />
|
||||
精华
|
||||
</span>
|
||||
)}
|
||||
{post.status === 'pending' && (
|
||||
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
||||
)}
|
||||
{post.status === 'rejected' && (
|
||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<a href={href} className="post-title" onClick={onTitleClick}>
|
||||
{post.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{excerpt && <p className="post-excerpt">{excerpt}</p>}
|
||||
|
||||
<div className="post-foot">
|
||||
<div className="post-foot-left">
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
{hasImage && (
|
||||
<span className="post-stat post-stat--media" title="含图片">
|
||||
<ImageIcon aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
|
||||
<MessageCircle aria-hidden />
|
||||
{commentCount}
|
||||
</span>
|
||||
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
|
||||
<ThumbsUp aria-hidden />
|
||||
{likeCount}
|
||||
</span>
|
||||
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
|
||||
<Eye aria-hidden />
|
||||
{viewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PostListItem);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 帖子列表加载骨架屏(对齐卡片式列表) */
|
||||
export default function PostListSkeleton({ count = 8 }: Props) {
|
||||
return (
|
||||
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="post-row post-row--skeleton">
|
||||
<Skeleton className="skeleton--avatar" />
|
||||
<div className="post-body">
|
||||
<div className="post-head">
|
||||
<div className="skeleton-meta-row">
|
||||
<Skeleton className="skeleton--meta" />
|
||||
<Skeleton className="skeleton--meta skeleton--meta-short" />
|
||||
</div>
|
||||
{i % 4 === 0 && <Skeleton className="skeleton--badge" />}
|
||||
</div>
|
||||
<Skeleton className="skeleton--title" style={{ width: `${58 + (i % 4) * 9}%` }} />
|
||||
<Skeleton className="skeleton--excerpt" style={{ width: `${72 + (i % 3) * 8}%` }} />
|
||||
<div className="post-foot">
|
||||
<Skeleton className="skeleton--badge" />
|
||||
<div className="post-stats">
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostRevision } from '../api/types';
|
||||
import PostContent from './PostContent';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import UserLink from './UserLink';
|
||||
import {
|
||||
type PostSnapshot,
|
||||
htmlToDiffText,
|
||||
summarizeChange,
|
||||
diffTextLines,
|
||||
diffTextWords,
|
||||
countLineChanges,
|
||||
} from '../utils/revisionDiff';
|
||||
|
||||
interface Props {
|
||||
postId: number;
|
||||
currentPost: PostSnapshot;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = 'diff' | 'before' | 'after';
|
||||
|
||||
const VIEW_MODES = [
|
||||
['diff', GitCompare, '变更对比'],
|
||||
['before', FileText, '编辑前'],
|
||||
['after', ArrowRight, '编辑后'],
|
||||
] as const;
|
||||
|
||||
interface RevisionEntry {
|
||||
rev: PostRevision;
|
||||
after: PostSnapshot;
|
||||
summary: ReturnType<typeof summarizeChange>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function DiffWords({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextWords(before, after);
|
||||
if (before === after) {
|
||||
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="revision-diff-inline">
|
||||
{parts.map((part, i) => {
|
||||
if (part.added) return <ins key={i}>{part.value}</ins>;
|
||||
if (part.removed) return <del key={i}>{part.value}</del>;
|
||||
return <span key={i}>{part.value}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLines({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextLines(before, after);
|
||||
const { added, removed } = countLineChanges(parts);
|
||||
|
||||
if (before === after) {
|
||||
return <p className="revision-diff-unchanged">正文无变化</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revision-diff-lines">
|
||||
<div className="revision-diff-stats">
|
||||
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del">删除 {removed} 行</span>}
|
||||
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add">新增 {added} 行</span>}
|
||||
</div>
|
||||
<pre className="revision-diff-pre">
|
||||
{parts.map((part, i) => {
|
||||
const lines = part.value.split('\n');
|
||||
return lines.map((line, j) => {
|
||||
if (j === lines.length - 1 && line === '') return null;
|
||||
const cls = part.added
|
||||
? 'revision-diff-line revision-diff-line--add'
|
||||
: part.removed
|
||||
? 'revision-diff-line revision-diff-line--del'
|
||||
: 'revision-diff-line revision-diff-line--same';
|
||||
const prefix = part.added ? '+' : part.removed ? '−' : ' ';
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={cls}>
|
||||
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
|
||||
<span className="revision-diff-text">{line || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
|
||||
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none">无变更</span>;
|
||||
return (
|
||||
<>
|
||||
{summary.titleChanged && <span className="revision-badge">标题</span>}
|
||||
{summary.contentChanged && <span className="revision-badge">正文</span>}
|
||||
{summary.tagsChanged && <span className="revision-badge">标签</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
|
||||
const [revisions, setRevisions] = useState<PostRevision[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('diff');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const viewTabsRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => onClose(), [onClose]);
|
||||
useOverlayA11y(open, handleClose, panelRef, { initialFocusRef: closeRef });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
setViewMode('diff');
|
||||
setFullscreen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api.postRevisions(postId)
|
||||
.then(d => {
|
||||
const list = d.revisions ?? [];
|
||||
setRevisions(list);
|
||||
if (list.length > 0) setSelectedId(list[0].id);
|
||||
})
|
||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, postId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [open]);
|
||||
|
||||
const entries: RevisionEntry[] = useMemo(() => {
|
||||
return revisions.map((rev, index) => {
|
||||
const after: PostSnapshot = index === 0
|
||||
? currentPost
|
||||
: {
|
||||
title: revisions[index - 1].title,
|
||||
content: revisions[index - 1].content,
|
||||
tags: revisions[index - 1].tags,
|
||||
};
|
||||
const before: PostSnapshot = {
|
||||
title: rev.title,
|
||||
content: rev.content,
|
||||
tags: rev.tags,
|
||||
};
|
||||
return {
|
||||
rev,
|
||||
after,
|
||||
summary: summarizeChange(before, after),
|
||||
index: revisions.length - index,
|
||||
};
|
||||
});
|
||||
}, [revisions, currentPost]);
|
||||
|
||||
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const beforeSnap: PostSnapshot | null = selected
|
||||
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑历史"
|
||||
>
|
||||
<header className="post-revision-head">
|
||||
<div className="post-revision-head-left">
|
||||
<History size={18} aria-hidden />
|
||||
<h3>编辑历史</h3>
|
||||
{selected && (
|
||||
<span className="post-revision-head-sub">
|
||||
第 {selected.index} 次编辑
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="post-revision-head-actions">
|
||||
<div
|
||||
ref={viewTabsRef}
|
||||
className="post-revision-view-tabs"
|
||||
role="tablist"
|
||||
aria-label="视图模式"
|
||||
onKeyDown={(e) => {
|
||||
const idx = VIEW_MODES.findIndex(([m]) => m === viewMode);
|
||||
const next = moveTabIndex(e.key, Math.max(0, idx), VIEW_MODES.length);
|
||||
if (next == null) return;
|
||||
e.preventDefault();
|
||||
setViewMode(VIEW_MODES[next][0]);
|
||||
requestAnimationFrame(() => {
|
||||
viewTabsRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{VIEW_MODES.map(([mode, Icon, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={viewMode === mode ? 0 : -1}
|
||||
aria-selected={viewMode === mode}
|
||||
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={14} aria-hidden />
|
||||
<span className="post-revision-tab-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="post-revision-icon-btn"
|
||||
onClick={() => setFullscreen(f => !f)}
|
||||
title={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={18} aria-hidden /> : <Maximize2 size={18} aria-hidden />}
|
||||
</button>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="post-revision-icon-btn"
|
||||
onClick={handleClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="post-revision-loading"><Spinner size="lg" /></div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="post-revision-empty">暂无编辑记录</p>
|
||||
) : (
|
||||
<div className="post-revision-body">
|
||||
<aside className="post-revision-sidebar">
|
||||
<div className="post-revision-sidebar-label">时间线</div>
|
||||
<ul className="post-revision-list">
|
||||
{entries.map(entry => (
|
||||
<li key={entry.rev.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(entry.rev.id)}
|
||||
>
|
||||
<div className="post-revision-item-head">
|
||||
<span className="post-revision-item-num">#{entry.index}</span>
|
||||
<ChangeBadges summary={entry.summary} />
|
||||
</div>
|
||||
<span className="post-revision-item-title">{entry.rev.title}</span>
|
||||
<span className="post-revision-item-meta">
|
||||
<UserLink
|
||||
user={entry.rev.editor
|
||||
? entry.rev.editor
|
||||
: { nickname: '未知' }}
|
||||
stopPropagation
|
||||
className="post-revision-editor-link"
|
||||
/>
|
||||
{' · '}{formatDateTime(entry.rev.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="post-revision-current">
|
||||
<span className="post-revision-current-label">当前版本</span>
|
||||
<span className="post-revision-current-title">{currentPost.title}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="post-revision-main">
|
||||
{selected && beforeSnap ? (
|
||||
<>
|
||||
<div className="post-revision-main-head">
|
||||
<div>
|
||||
<span className="post-revision-main-editor">
|
||||
<UserLink
|
||||
user={selected.rev.editor
|
||||
? selected.rev.editor
|
||||
: { nickname: '未知' }}
|
||||
className="post-revision-editor-link"
|
||||
/>
|
||||
</span>
|
||||
<span className="post-revision-main-time">
|
||||
{formatDateTime(selected.rev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<ChangeBadges summary={selected.summary} />
|
||||
</div>
|
||||
|
||||
<div className="post-revision-scroll">
|
||||
{viewMode === 'diff' && (
|
||||
<div className="revision-diff-view">
|
||||
<section className="revision-diff-section">
|
||||
<h4>标题</h4>
|
||||
<div className="revision-diff-block">
|
||||
<DiffWords before={beforeSnap.title} after={selected.after.title} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(beforeSnap.tags || selected.after.tags) && (
|
||||
<section className="revision-diff-section">
|
||||
<h4>标签</h4>
|
||||
<div className="revision-diff-block">
|
||||
<DiffWords
|
||||
before={beforeSnap.tags || '(无)'}
|
||||
after={selected.after.tags || '(无)'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="revision-diff-section revision-diff-section--content">
|
||||
<h4>正文</h4>
|
||||
<DiffLines
|
||||
before={htmlToDiffText(beforeSnap.content)}
|
||||
after={htmlToDiffText(selected.after.content)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'before' && (
|
||||
<div className="revision-full-view">
|
||||
<h4>{beforeSnap.title}</h4>
|
||||
{beforeSnap.tags && (
|
||||
<p className="revision-full-tags">标签:{beforeSnap.tags}</p>
|
||||
)}
|
||||
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'after' && (
|
||||
<div className="revision-full-view">
|
||||
<h4>{selected.after.title}</h4>
|
||||
{selected.after.tags && (
|
||||
<p className="revision-full-tags">标签:{selected.after.tags}</p>
|
||||
)}
|
||||
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="post-revision-empty">请从左侧选择一次编辑记录</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, ArrowDown } from 'lucide-react';
|
||||
|
||||
/** 触发刷新的下拉距离(px) */
|
||||
const REFRESH_THRESHOLD = 68;
|
||||
/** 指示器最大位移 */
|
||||
const PULL_MAX = 108;
|
||||
/** 判定为「下拉」意图的最小位移,避免误触滚动 */
|
||||
const ARM_DELTA = 10;
|
||||
/** 指示器休息位在视口上方的隐藏量,下拉时再滑入 */
|
||||
const INDICATOR_HIDE = 40;
|
||||
|
||||
/**
|
||||
* 定位当前真正滚动的容器。
|
||||
* SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。
|
||||
* 发帖/编辑页不参与:主栏 overflow:hidden,滚动在编辑器内层,绑 PTR 会误触并打断编辑。
|
||||
*/
|
||||
function pickScrollEl(): HTMLElement | null {
|
||||
if (document.querySelector('.main-content--compose, .compose-page')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
|
||||
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
||||
if (mobileFeed) return mobileFeed;
|
||||
|
||||
const list = document.querySelector<HTMLElement>('.post-list-scroll');
|
||||
if (list) return list;
|
||||
|
||||
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
||||
if (page) return page;
|
||||
|
||||
const admin = document.querySelector<HTMLElement>('.admin-main');
|
||||
if (admin) return admin;
|
||||
|
||||
const auth = document.querySelector<HTMLElement>('.auth-page');
|
||||
if (auth) return auth;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return window.matchMedia('(hover: none) and (pointer: coarse)').matches
|
||||
|| navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
/** 输入框 / 富文本编辑中不触发下拉刷新 */
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'textarea, input, select, [contenteditable="true"], .ProseMirror, .article-editor, .compose-page',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触摸落在内层可滚动区域且该区域未到顶时,交给内层滚动,不武装 PTR。
|
||||
*/
|
||||
function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement): boolean {
|
||||
let node: Element | null = target instanceof Element ? target : null;
|
||||
while (node && node !== bound) {
|
||||
if (node instanceof HTMLElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (
|
||||
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
|
||||
&& node.scrollHeight > node.clientHeight + 1
|
||||
&& node.scrollTop > 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
|
||||
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
|
||||
*
|
||||
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
|
||||
*/
|
||||
export default function PullToRefresh() {
|
||||
const [pull, setPull] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const pullRef = useRef(0);
|
||||
const refreshingRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const trackingRef = useRef(false);
|
||||
const pullingRef = useRef(false);
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pullRef.current = pull;
|
||||
}, [pull]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshingRef.current = refreshing;
|
||||
}, [refreshing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTouchDevice()) return;
|
||||
|
||||
let cancelled = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let bound: HTMLElement | null = null;
|
||||
|
||||
const resetGesture = () => {
|
||||
trackingRef.current = false;
|
||||
pullingRef.current = false;
|
||||
startYRef.current = 0;
|
||||
if (!refreshingRef.current) {
|
||||
setSettling(false);
|
||||
setPull(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (refreshingRef.current || e.touches.length !== 1) return;
|
||||
const el = scrollElRef.current;
|
||||
if (!el || el.scrollTop > 1) return;
|
||||
if (document.querySelector(
|
||||
'.sidebar-drawer-root, .aside-drawer-root, .image-lightbox, [aria-modal="true"]',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (isEditableTarget(e.target)) return;
|
||||
if (isNestedScrollBlocking(e.target, el)) return;
|
||||
trackingRef.current = true;
|
||||
pullingRef.current = false;
|
||||
setSettling(false);
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!trackingRef.current || refreshingRef.current || e.touches.length !== 1) return;
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop > 1) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
const dy = e.touches[0].clientY - startYRef.current;
|
||||
if (dy < ARM_DELTA) {
|
||||
if (pullingRef.current && dy <= 0) resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
pullingRef.current = true;
|
||||
setSettling(false);
|
||||
setPull(Math.min(PULL_MAX, dy * 0.55));
|
||||
if (e.cancelable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!trackingRef.current) return;
|
||||
const shouldRefresh = pullingRef.current && pullRef.current >= REFRESH_THRESHOLD;
|
||||
trackingRef.current = false;
|
||||
pullingRef.current = false;
|
||||
|
||||
if (shouldRefresh) {
|
||||
setRefreshing(true);
|
||||
setSettling(true);
|
||||
setPull(REFRESH_THRESHOLD * 0.7);
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
setSettling(true);
|
||||
setPull(0);
|
||||
};
|
||||
|
||||
const unbind = () => {
|
||||
if (!bound) return;
|
||||
bound.removeEventListener('touchstart', onTouchStart);
|
||||
bound.removeEventListener('touchmove', onTouchMove);
|
||||
bound.removeEventListener('touchend', onTouchEnd);
|
||||
bound.removeEventListener('touchcancel', onTouchEnd);
|
||||
bound = null;
|
||||
};
|
||||
|
||||
const bind = (el: HTMLElement) => {
|
||||
if (bound === el) return;
|
||||
unbind();
|
||||
bound = el;
|
||||
scrollElRef.current = el;
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
el.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
el.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
};
|
||||
|
||||
const tryBind = () => {
|
||||
if (cancelled) return;
|
||||
const next = pickScrollEl();
|
||||
if (next) {
|
||||
bind(next);
|
||||
return;
|
||||
}
|
||||
// 发帖页等:卸掉旧绑定并收起指示器,避免残留气泡 / 误触
|
||||
if (!bound && !scrollElRef.current && pullRef.current <= 0 && !trackingRef.current) {
|
||||
return;
|
||||
}
|
||||
unbind();
|
||||
scrollElRef.current = null;
|
||||
resetGesture();
|
||||
};
|
||||
|
||||
const scheduleBind = () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(tryBind, 40);
|
||||
};
|
||||
|
||||
tryBind();
|
||||
|
||||
const root = document.getElementById('root') ?? document.body;
|
||||
const mo = new MutationObserver(scheduleBind);
|
||||
mo.observe(root, { childList: true, subtree: true });
|
||||
window.addEventListener('popstate', scheduleBind);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounceTimer);
|
||||
mo.disconnect();
|
||||
window.removeEventListener('popstate', scheduleBind);
|
||||
unbind();
|
||||
scrollElRef.current = null;
|
||||
trackingRef.current = false;
|
||||
pullingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!refreshing && pull <= 0) return null;
|
||||
|
||||
const ready = pull >= REFRESH_THRESHOLD || refreshing;
|
||||
const offset = Math.max(pull, refreshing ? 48 : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'ptr-indicator',
|
||||
ready ? 'ptr-indicator--ready' : '',
|
||||
refreshing ? 'ptr-indicator--refreshing' : '',
|
||||
settling ? 'ptr-indicator--settling' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{ transform: `translateY(${offset - INDICATOR_HIDE}px)` }}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="ptr-indicator__chip">
|
||||
{refreshing ? (
|
||||
<Loader2 size={18} className="ptr-indicator__spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowDown
|
||||
size={18}
|
||||
className="ptr-indicator__arrow"
|
||||
style={{ transform: ready ? 'rotate(180deg)' : undefined }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span>{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatShortDateTime } from '../utils/content';
|
||||
import TagCloud from './TagCloud';
|
||||
import UserLink from './UserLink';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
import PostAuthorCard from './PostAuthorCard';
|
||||
|
||||
export type PostDetailAside = {
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
headings: PostHeading[];
|
||||
scrollRoot?: HTMLElement | null;
|
||||
outlineTitle?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
hot: PostItem[];
|
||||
recentComments: RecentComment[];
|
||||
tags?: TagCount[];
|
||||
tagsLoading?: boolean;
|
||||
onPostClick: (id: number, opts?: { floor?: number }) => void;
|
||||
/** 首次拉取中,显示骨架避免空态闪烁 */
|
||||
loading?: boolean;
|
||||
/** 帖子详情:右侧顶部展示作者与目录 */
|
||||
postDetail?: PostDetailAside | null;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
if (index === 0) return 'widget-rank widget-rank--1';
|
||||
if (index === 1) return 'widget-rank widget-rank--2';
|
||||
if (index === 2) return 'widget-rank widget-rank--3';
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
function HotSkeleton() {
|
||||
return (
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="widget-item widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-rank" />
|
||||
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentSkeleton() {
|
||||
return (
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-avatar" />
|
||||
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
|
||||
<Skeleton className="skeleton--widget-time" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPanel({
|
||||
hot,
|
||||
recentComments,
|
||||
tags = [],
|
||||
tagsLoading = false,
|
||||
onPostClick,
|
||||
loading = false,
|
||||
postDetail = null,
|
||||
}: Props) {
|
||||
const { branding } = useSiteBranding();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get('keyword') || '';
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||
// 站点首页:右侧品牌块承担唯一 h1;板块/搜索等页面由 Feed 标题作 h1
|
||||
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
|
||||
const description = branding.description?.trim() || '';
|
||||
const slogan = branding.slogan?.trim() || '';
|
||||
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
||||
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
|
||||
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
|
||||
const showHot = loading || hotList.length >= 4;
|
||||
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
|
||||
const isPostDetail = !!postDetail;
|
||||
|
||||
return (
|
||||
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
|
||||
{isPostDetail && (
|
||||
<>
|
||||
<PostAuthorCard
|
||||
author={postDetail.author}
|
||||
publishedAt={postDetail.publishedAt}
|
||||
viewCount={postDetail.viewCount}
|
||||
/>
|
||||
<div className="widget-card widget-card--outline">
|
||||
<div className="widget-card-head">
|
||||
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />
|
||||
{postDetail.outlineTitle || '文章目录'}
|
||||
</div>
|
||||
<div className="widget-card-body widget-outline-body">
|
||||
<ArticleOutline
|
||||
headings={postDetail.headings}
|
||||
scrollRoot={postDetail.scrollRoot}
|
||||
title={postDetail.outlineTitle || '文章目录'}
|
||||
className="article-outline--aside"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isPostDetail && showWelcome && (
|
||||
<div className="widget-card widget-card--welcome">
|
||||
<div className="widget-card-head">
|
||||
<Sparkles className="widget-card-icon widget-card-icon--welcome" aria-hidden />
|
||||
加入讨论
|
||||
</div>
|
||||
<div className="widget-card-body widget-welcome-body">
|
||||
<p>社区还在起步,每条回复都很珍贵。</p>
|
||||
<ul>
|
||||
<li>逛逛板块,找到感兴趣的话题</li>
|
||||
<li>游客也能评论,登录可点赞收藏</li>
|
||||
<li>发一篇帖,留下你的痕迹</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && showHot && (
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<HotSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<div className="widget-card widget-card--tags">
|
||||
<div className="widget-card-head">
|
||||
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
|
||||
标签云
|
||||
</div>
|
||||
<div className="widget-card-body widget-card-body--tags">
|
||||
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新评论
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && commentList.length === 0 ? (
|
||||
<CommentSkeleton />
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="widget-item widget-item--comment"
|
||||
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||
>
|
||||
{item.user_id ? (
|
||||
<UserLink
|
||||
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="widget-item-avatar user-link--avatar-only"
|
||||
>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</UserLink>
|
||||
) : (
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="widget-item-comment-main"
|
||||
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
|
||||
>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{formatShortDateTime(item.created_at)}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<div className="widget-card widget-card--about">
|
||||
<div className="widget-card-body">
|
||||
<div className="widget-about-text">
|
||||
{isSiteHome ? (
|
||||
<h1 className="widget-about-title">{branding.name}</h1>
|
||||
) : (
|
||||
<p className="widget-about-title">{branding.name}</p>
|
||||
)}
|
||||
<p className="widget-about-desc">{aboutText}</p>
|
||||
{description && slogan && slogan !== description && (
|
||||
<p className="widget-about-slogan">{slogan}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import BoardIconDisplay from './BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/'];
|
||||
|
||||
export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
// 搜索结果不属于「全部帖子」或某一板块,取消侧栏选中高亮
|
||||
if (keyword.trim()) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/projects')) return 'projects';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
boards: Board[];
|
||||
activeBoard: number;
|
||||
onSelectBoard: (id: number) => void;
|
||||
/** 板块列表首次拉取中 */
|
||||
boardsLoading?: boolean;
|
||||
/** 帖子详情:左侧切换为文章目录 */
|
||||
outlineMode?: boolean;
|
||||
outlineHeadings?: PostHeading[];
|
||||
outlineScrollRoot?: HTMLElement | null;
|
||||
outlineTitle?: string;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
boards,
|
||||
activeBoard,
|
||||
onSelectBoard,
|
||||
boardsLoading = false,
|
||||
outlineMode = false,
|
||||
outlineHeadings = [],
|
||||
outlineScrollRoot = null,
|
||||
outlineTitle,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const keyword = params.get('keyword') || '';
|
||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
|
||||
|
||||
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === key && 'active')}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (outlineMode) {
|
||||
return (
|
||||
<aside className="sidebar sidebar--outline">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-nav-item sidebar-outline-back"
|
||||
onClick={() => navigateFeed(nav, '/')}
|
||||
>
|
||||
<ArrowLeft aria-hidden />
|
||||
<span className="flex-1 truncate">返回首页</span>
|
||||
</button>
|
||||
<ArticleOutline
|
||||
headings={outlineHeadings}
|
||||
scrollRoot={outlineScrollRoot}
|
||||
title={outlineTitle || '文章目录'}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
||||
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
|
||||
</nav>
|
||||
|
||||
{(boardsLoading && boards.length === 0) ? (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
|
||||
<Skeleton className="skeleton--sidebar-icon" />
|
||||
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
) : boards.length > 0 ? (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
{boards.map(b => {
|
||||
const isActive = menuKey != null && menuKey === String(b.id);
|
||||
const themeIdx = getBoardThemeIndex(b);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn(
|
||||
'sidebar-nav-item',
|
||||
'sidebar-nav-item--board',
|
||||
isActive && 'active',
|
||||
isActive && `sidebar-nav-item--board-${themeIdx}`,
|
||||
)}
|
||||
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<BoardIconDisplay
|
||||
board={b}
|
||||
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{(b.post_count ?? 0) > 0 && (
|
||||
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
|
||||
{b.post_count} 帖
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
) : isAdmin ? (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('boards-empty', '创建第一个板块', <FolderKanban aria-hidden />, () => nav('/admin/boards'))}
|
||||
</nav>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--spaced">管理</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('admin', '管理后台', <LayoutDashboard aria-hidden />, () => nav('/admin/dashboard'))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { SiteBranding } from '../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
branding: SiteBranding;
|
||||
/** CSS 类:header-logo-mark / logo-mark / admin-topbar-mark */
|
||||
className?: string;
|
||||
/** 有 Logo 图时用的额外类名 */
|
||||
imgClassName?: string;
|
||||
}
|
||||
|
||||
/** 站点字标或 Logo 图 */
|
||||
export default function SiteBrandMark({ branding, className, imgClassName }: Props) {
|
||||
if (branding.logo) {
|
||||
return (
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.name}
|
||||
className={cn(className, 'site-brand-logo-img', imgClassName)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className={className}>{branding.logo_mark || branding.name.charAt(0) || '?'}</span>;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useMediaQuery } from '../hooks/useTheme';
|
||||
import type { FriendLink } from '../api/types';
|
||||
|
||||
function FooterSep() {
|
||||
return <span className="site-footer__sep" aria-hidden>·</span>;
|
||||
}
|
||||
|
||||
/** 站点页脚:版权、Sitemap、友链、备案号 */
|
||||
export default function SiteFooter() {
|
||||
const { branding } = useSiteBranding();
|
||||
const year = new Date().getFullYear();
|
||||
const links = Array.isArray(branding.friend_links) ? branding.friend_links : [];
|
||||
const icp = branding.icp_beian?.trim() || '';
|
||||
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<div className="site-footer__meta">
|
||||
<span className="site-footer__copy">
|
||||
© {year} {branding.name}
|
||||
</span>
|
||||
{branding.slogan?.trim() && (
|
||||
<>
|
||||
<FooterSep />
|
||||
<span className="site-footer__slogan">{branding.slogan.trim()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(links.length > 0 || icp) && (
|
||||
<nav className="site-footer__nav" aria-label="站点链接">
|
||||
{links.map((link: FriendLink, i) => (
|
||||
<span key={`${link.name}-${link.url}`} className="site-footer__friend">
|
||||
{i > 0 && <FooterSep />}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
{icp && (
|
||||
<>
|
||||
{links.length > 0 && <FooterSep />}
|
||||
<a
|
||||
href={icpURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="site-footer__icp"
|
||||
>
|
||||
{icp}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机端随内容滚动的页脚(放在 .page-wrap / .post-list-scroll 末尾)。
|
||||
* 桌面端返回 null,由 MainLayout 壳层贴底页脚负责。
|
||||
*/
|
||||
export function InFlowSiteFooter() {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
if (!isMobile) return null;
|
||||
return <SiteFooter />;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { TagCount } from '../api/types';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
tags: TagCount[];
|
||||
loading?: boolean;
|
||||
activeTag?: string;
|
||||
}
|
||||
|
||||
type TagTone = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/** 稳定哈希,让同一标签颜色固定 */
|
||||
function hashTone(name: string): TagTone {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return (h % 6) as TagTone;
|
||||
}
|
||||
|
||||
/** 权重档位 0–4,驱动字号与透明度 */
|
||||
function weightTier(count: number, min: number, max: number): number {
|
||||
if (max <= min) return 2;
|
||||
const t = (count - min) / (max - min);
|
||||
return Math.min(4, Math.max(0, Math.round(t * 4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打散排序:热门标签穿插分布,避免「大标签全挤在顶上」。
|
||||
* 用名称哈希做次级键,视觉更像云而非排行榜。
|
||||
*/
|
||||
function layoutTags(tags: TagCount[]): TagCount[] {
|
||||
const ranked = [...tags].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh'));
|
||||
const top = ranked.slice(0, Math.min(6, ranked.length));
|
||||
const rest = ranked.slice(top.length);
|
||||
const out: TagCount[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < top.length || j < rest.length) {
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
if (i < top.length) out.push(top[i++]);
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 右侧栏标签云:按热度缩放,色调错落 */
|
||||
export default function TagCloud({ tags, loading = false, activeTag = '' }: Props) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const { items, min, max } = useMemo(() => {
|
||||
if (tags.length === 0) return { items: [] as TagCount[], min: 1, max: 1 };
|
||||
let lo = tags[0].count;
|
||||
let hi = tags[0].count;
|
||||
for (const t of tags) {
|
||||
if (t.count < lo) lo = t.count;
|
||||
if (t.count > hi) hi = t.count;
|
||||
}
|
||||
return { items: layoutTags(tags), min: lo, max: hi };
|
||||
}, [tags]);
|
||||
|
||||
if (loading && tags.length === 0) {
|
||||
return (
|
||||
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="skeleton--tag-cloud"
|
||||
style={{
|
||||
width: `${42 + (i % 5) * 16}px`,
|
||||
height: `${20 + (i % 3) * 4}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="tag-cloud-empty">暂无标签</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-cloud" role="list" aria-label="标签云">
|
||||
{items.map((tag, index) => {
|
||||
const active = activeTag.trim().toLowerCase() === tag.name.toLowerCase();
|
||||
const tier = weightTier(tag.count, min, max);
|
||||
const tone = hashTone(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.name}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={cn(
|
||||
'tag-cloud-item',
|
||||
`tag-cloud-item--w${tier}`,
|
||||
`tag-cloud-item--t${tone}`,
|
||||
`tag-cloud-item--r${index % 5}`,
|
||||
active && 'active',
|
||||
)}
|
||||
title={`${tag.name} · ${tag.count} 篇`}
|
||||
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
||||
>
|
||||
<span className="tag-cloud-item__name">{tag.name}</span>
|
||||
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Tag, X } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export function parseTags(raw: string): string[] {
|
||||
return raw.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function serializeTags(list: string[]): string {
|
||||
return list.join(',');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** 序列化后的总长度上限,0/undefined 表示不限 */
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 回车 / 逗号确认标签块,悬停显示删除 */
|
||||
export default function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '输入标签后回车',
|
||||
maxLength,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tags = parseTags(value);
|
||||
const commit = (raw: string) => {
|
||||
const next = raw.trim();
|
||||
if (!next) return false;
|
||||
|
||||
if (tags.some((t) => t.toLowerCase() === next.toLowerCase())) {
|
||||
setDraft('');
|
||||
return false;
|
||||
}
|
||||
|
||||
const merged = serializeTags([...tags, next]);
|
||||
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
|
||||
notify.warning(`标签总长不能超过 ${maxLength} 字`);
|
||||
return false;
|
||||
}
|
||||
|
||||
onChange(merged);
|
||||
setDraft('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
onChange(serializeTags(tags.filter((_, i) => i !== index)));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && !draft && tags.length > 0) {
|
||||
e.preventDefault();
|
||||
removeAt(tags.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onDraftChange = (text: string) => {
|
||||
// 粘贴或输入含分隔符时立即拆成多个标签
|
||||
if (/[,,]/.test(text)) {
|
||||
const parts = parseTags(text);
|
||||
const lastSep = Math.max(text.lastIndexOf(','), text.lastIndexOf(','));
|
||||
const trailing = lastSep >= 0 && lastSep === text.length - 1 ? '' : text.slice(lastSep + 1);
|
||||
let list = [...tags];
|
||||
for (const p of parts) {
|
||||
if (list.some((t) => t.toLowerCase() === p.toLowerCase())) continue;
|
||||
const merged = serializeTags([...list, p]);
|
||||
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
|
||||
notify.warning(`标签总长不能超过 ${maxLength} 字`);
|
||||
break;
|
||||
}
|
||||
list = [...list, p];
|
||||
}
|
||||
onChange(serializeTags(list));
|
||||
setDraft(trailing.replace(/^[,,]+/, '').trimStart());
|
||||
return;
|
||||
}
|
||||
setDraft(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`compose-tags-field${disabled ? ' compose-tags-field--disabled' : ''}`}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<Tag className="compose-tags-icon" size={16} aria-hidden />
|
||||
<div className="compose-tags-chips">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={`${tag}-${i}`} className="compose-tag-chip">
|
||||
<span className="compose-tag-chip-label">{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="compose-tag-chip-remove"
|
||||
aria-label={`删除标签 ${tag}`}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeAt(i);
|
||||
}}
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="compose-tags-input"
|
||||
placeholder={tags.length === 0 ? placeholder : '继续添加…'}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => {
|
||||
if (draft.trim()) commit(draft);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onStay: () => void;
|
||||
onLeave: () => void;
|
||||
/** 编辑已有帖子时为 true */
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent onEscapeKeyDown={onStay}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>放弃未保存的修改?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isEdit
|
||||
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
|
||||
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onStay}>继续编辑</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onLeave}
|
||||
>
|
||||
放弃并离开
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { BadgeCheck, Crown, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserBadge } from '../api/types';
|
||||
import { badgeIcon } from '../utils/badgeIcons';
|
||||
import { resolveUserLevel } from '../utils/userMeta';
|
||||
|
||||
type BadgeUser = {
|
||||
role?: string;
|
||||
verified?: boolean;
|
||||
level?: number;
|
||||
exp?: number;
|
||||
badges?: UserBadge[];
|
||||
} | null | undefined;
|
||||
|
||||
interface Props {
|
||||
user: BadgeUser;
|
||||
className?: string;
|
||||
/** 用户名旁最多展示几枚成就徽章 */
|
||||
maxAchievement?: number;
|
||||
showLevel?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** 用户名旁:站长/认证 + Lv + 成就徽章 */
|
||||
export default function UserBadges({
|
||||
user,
|
||||
className,
|
||||
maxAchievement = 2,
|
||||
showLevel = true,
|
||||
compact = true,
|
||||
}: Props) {
|
||||
if (!user) return null;
|
||||
const level = resolveUserLevel(user);
|
||||
const achievements = (user.badges ?? []).slice(0, maxAchievement);
|
||||
const isAdmin = user.role === 'admin';
|
||||
const isVerified = !!user.verified && !isAdmin;
|
||||
|
||||
if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null;
|
||||
|
||||
const levelTone = level >= 9 ? 'gold' : level >= 7 ? 'amber' : level >= 4 ? 'blue' : 'muted';
|
||||
|
||||
return (
|
||||
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
|
||||
{isAdmin && (
|
||||
<span className="user-badge user-badge--owner" title="站长">
|
||||
<Crown size={compact ? 12 : 14} aria-hidden />
|
||||
{!compact && <span>站长</span>}
|
||||
</span>
|
||||
)}
|
||||
{isVerified && (
|
||||
<span className="user-badge user-badge--verified" title="认证用户">
|
||||
<BadgeCheck size={compact ? 12 : 14} aria-hidden />
|
||||
{!compact && <span>认证</span>}
|
||||
</span>
|
||||
)}
|
||||
{showLevel && (
|
||||
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
|
||||
Lv.{level}
|
||||
</span>
|
||||
)}
|
||||
{achievements.map(b => {
|
||||
const Icon: LucideIcon = badgeIcon(b.icon);
|
||||
return (
|
||||
<span key={b.code} className="user-badge user-badge--ach" title={`${b.name}:${b.description}`}>
|
||||
<Icon size={compact ? 11 : 13} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserBadge } from '../api/types';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import UserBadges from './UserBadges';
|
||||
|
||||
export type UserLinkUser = {
|
||||
id?: number;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
verified?: boolean;
|
||||
level?: number;
|
||||
exp?: number;
|
||||
badges?: UserBadge[];
|
||||
} | null | undefined;
|
||||
|
||||
interface Props {
|
||||
user: UserLinkUser;
|
||||
className?: string;
|
||||
avatarClassName?: string;
|
||||
nameClassName?: string;
|
||||
showAvatar?: boolean;
|
||||
showName?: boolean;
|
||||
showBadges?: boolean;
|
||||
/** 嵌在可点击父级内时阻止冒泡(如帖子列表行) */
|
||||
stopPropagation?: boolean;
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 统一用户入口:点击进入 /user/:id 公开主页 */
|
||||
export default function UserLink({
|
||||
user,
|
||||
className,
|
||||
avatarClassName,
|
||||
nameClassName,
|
||||
showAvatar = false,
|
||||
showName = true,
|
||||
showBadges = false,
|
||||
stopPropagation = false,
|
||||
children,
|
||||
title,
|
||||
}: Props) {
|
||||
const id = user?.id && user.id > 0 ? user.id : 0;
|
||||
const nick = user?.nickname?.trim() || '匿名';
|
||||
const initial = nick[0] || '?';
|
||||
const tip = title || nick;
|
||||
|
||||
const onClick = stopPropagation
|
||||
? (e: MouseEvent) => { e.stopPropagation(); }
|
||||
: undefined;
|
||||
|
||||
const body = children ?? (
|
||||
<>
|
||||
{showAvatar && (
|
||||
<span className={cn('user-link-avatar', avatarClassName)} aria-hidden>
|
||||
{user?.avatar
|
||||
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</span>
|
||||
)}
|
||||
{showName && <span className={cn('user-link-name', nameClassName)}>{nick}</span>}
|
||||
{showBadges && showName && <UserBadges user={user} />}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<span className={cn('user-link user-link--static', className)} title={tip}>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={userPath(id)}
|
||||
className={cn('user-link', className)}
|
||||
title={tip}
|
||||
onClick={onClick}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,286 +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 PostListSkeleton from './PostListSkeleton';
|
||||
import FeedPagination from './FeedPagination';
|
||||
import { InFlowSiteFooter } from './SiteFooter';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useMediaQuery } from '../hooks/useTheme';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } 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;
|
||||
/** 当前板块 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 = 'latest',
|
||||
loading,
|
||||
hasMore,
|
||||
showPagination,
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
keyword = '',
|
||||
boardId = 0,
|
||||
boardName = '',
|
||||
noBoards = false,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
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: () => 108,
|
||||
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 && !!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(() => {
|
||||
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(() => {
|
||||
restoredRef.current = false;
|
||||
}, [restoreScrollTop]);
|
||||
|
||||
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 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={() => nav('/')}>
|
||||
返回全部帖子
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
|
||||
{user ? '发帖' : '登录后发帖'}
|
||||
</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 ? (
|
||||
<PostListSkeleton />
|
||||
) : 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
|
||||
? '试试更短的关键词,或浏览标签云 / 板块'
|
||||
: 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} 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>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
CODE_BLOCK_LANGUAGES,
|
||||
DEFAULT_CODE_BLOCK_OPTIONS,
|
||||
loadCodeBlockPrefs,
|
||||
saveCodeBlockPrefs,
|
||||
type CodeBlockInsertOptions,
|
||||
} from '../../utils/codeBlockOptions';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 编辑已有代码块时的初始值;新建时用偏好 */
|
||||
initial?: Partial<CodeBlockInsertOptions> | null;
|
||||
/** 是否处于已有代码块(显示「移除」) */
|
||||
editing?: boolean;
|
||||
onConfirm: (opts: CodeBlockInsertOptions) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** 文章编辑器:插入 / 配置代码块 */
|
||||
export function ArticleCodeBlockDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
editing = false,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [language, setLanguage] = useState(DEFAULT_CODE_BLOCK_OPTIONS.language);
|
||||
const [lineNumbers, setLineNumbers] = useState(DEFAULT_CODE_BLOCK_OPTIONS.lineNumbers);
|
||||
const [collapsed, setCollapsed] = useState(DEFAULT_CODE_BLOCK_OPTIONS.collapsed);
|
||||
const [langQuery, setLangQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prefs = loadCodeBlockPrefs();
|
||||
setLanguage(initial?.language ?? '');
|
||||
setLineNumbers(initial?.lineNumbers ?? prefs.lineNumbers);
|
||||
setCollapsed(initial?.collapsed ?? prefs.collapsed);
|
||||
setLangQuery('');
|
||||
}, [open, initial]);
|
||||
|
||||
const filteredLangs = useMemo(() => {
|
||||
const q = langQuery.trim().toLowerCase();
|
||||
if (!q) return CODE_BLOCK_LANGUAGES;
|
||||
return CODE_BLOCK_LANGUAGES.filter(
|
||||
l => l.id.includes(q) || l.label.toLowerCase().includes(q),
|
||||
);
|
||||
}, [langQuery]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const opts: CodeBlockInsertOptions = {
|
||||
language: language.trim().toLowerCase(),
|
||||
lineNumbers,
|
||||
collapsed,
|
||||
};
|
||||
saveCodeBlockPrefs(opts);
|
||||
onConfirm(opts);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-codeblock-dialog sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '代码块设置' : '插入代码块'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择语言与阅读展示。外观随站点亮/暗主题自动切换。源码模式会写成{' '}
|
||||
<code className="article-codeblock-dialog__codehint">{'```js lines collapsed'}</code>
|
||||
{' '}这类围栏,可直接手改。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="article-codeblock-dialog__body">
|
||||
<section className="article-codeblock-dialog__section">
|
||||
<Label htmlFor="codeblock-lang-search">语言</Label>
|
||||
<Input
|
||||
id="codeblock-lang-search"
|
||||
value={langQuery}
|
||||
placeholder="搜索语言…"
|
||||
onChange={e => setLangQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="article-codeblock-dialog__langs" role="listbox" aria-label="编程语言">
|
||||
{filteredLangs.map(lang => {
|
||||
const active = language === lang.id;
|
||||
return (
|
||||
<button
|
||||
key={lang.id || 'plain'}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`article-codeblock-dialog__lang${active ? ' is-active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.label}
|
||||
{lang.id ? <span className="article-codeblock-dialog__lang-id">{lang.id}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredLangs.length === 0 ? (
|
||||
<p className="article-codeblock-dialog__empty">
|
||||
无匹配项。可直接使用下方自定义标识。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={language}
|
||||
placeholder="自定义语言标识(如 aardio)"
|
||||
onChange={e => setLanguage(e.target.value.trim().toLowerCase())}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="article-codeblock-dialog__toggles">
|
||||
<div className="article-codeblock-dialog__toggle">
|
||||
<div className="article-codeblock-dialog__toggle-text">
|
||||
<Label htmlFor="codeblock-lines">显示行号</Label>
|
||||
<p>阅读态在左侧标注行号</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="codeblock-lines"
|
||||
checked={lineNumbers}
|
||||
onCheckedChange={setLineNumbers}
|
||||
/>
|
||||
</div>
|
||||
<div className="article-codeblock-dialog__toggle">
|
||||
<div className="article-codeblock-dialog__toggle-text">
|
||||
<Label htmlFor="codeblock-fold">默认折叠</Label>
|
||||
<p>阅读时先收起;不足 5 行不显示折叠按钮</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="codeblock-fold"
|
||||
checked={collapsed}
|
||||
onCheckedChange={setCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="article-codeblock-dialog__footer">
|
||||
{editing && onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="article-codeblock-dialog__remove"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
移除代码块
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
{editing ? '应用' : '插入'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import CodeBlock from '@tiptap/extension-code-block';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { createCodeBlockHighlightPlugin } from '../../utils/codeBlockHighlightPlugin';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
articleCodeBlock: {
|
||||
/** 按选项插入或更新代码块 */
|
||||
setArticleCodeBlock: (attrs: {
|
||||
language?: string | null;
|
||||
lineNumbers?: boolean;
|
||||
collapsed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatLangLabel(language: string): string {
|
||||
if (language === 'aardio') return 'aardio';
|
||||
return language || 'code';
|
||||
}
|
||||
|
||||
/** 同步编辑态外壳:主题壳 + 语言 + 选项角标(外观随站点主题,不手选风格) */
|
||||
function applyEditorChrome(ctx: {
|
||||
wrap: HTMLElement;
|
||||
langEl: HTMLElement;
|
||||
metaEl: HTMLElement;
|
||||
pre: HTMLElement;
|
||||
code: HTMLElement;
|
||||
node: ProseMirrorNode;
|
||||
}) {
|
||||
const { wrap, langEl, metaEl, pre, code, node } = ctx;
|
||||
const lineNumbers = Boolean(node.attrs.lineNumbers);
|
||||
const collapsed = Boolean(node.attrs.collapsed);
|
||||
const language = ((node.attrs.language as string) || '').trim();
|
||||
|
||||
wrap.className = 'md-codeblock md-codeblock--editor';
|
||||
wrap.removeAttribute('data-code-style');
|
||||
if (lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
||||
else wrap.removeAttribute('data-line-numbers');
|
||||
if (collapsed) wrap.setAttribute('data-collapsed', 'true');
|
||||
else wrap.removeAttribute('data-collapsed');
|
||||
wrap.setAttribute('data-lang', formatLangLabel(language));
|
||||
|
||||
langEl.textContent = formatLangLabel(language);
|
||||
|
||||
const badges: string[] = [];
|
||||
if (lineNumbers) badges.push('行号');
|
||||
if (collapsed) badges.push('默认折叠');
|
||||
metaEl.textContent = badges.join(' · ');
|
||||
metaEl.hidden = badges.length === 0;
|
||||
|
||||
pre.className = 'md-codeblock__pre';
|
||||
code.className = [
|
||||
'hljs',
|
||||
language ? `language-${language}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章代码块:行号 / 折叠属性 + 编辑壳 + hljs 着色。
|
||||
* 外观跟站点亮/暗主题;行号列与折叠裁切仅在阅读态渲染。
|
||||
*/
|
||||
export const ArticleCodeBlock = CodeBlock.extend({
|
||||
name: 'codeBlock',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lineNumbers: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute('data-line-numbers') === 'true',
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.lineNumbers) return {};
|
||||
return { 'data-line-numbers': 'true' };
|
||||
},
|
||||
},
|
||||
collapsed: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute('data-collapsed') === 'true',
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.collapsed) return {};
|
||||
return { 'data-collapsed': 'true' };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node: initialNode, editor, getPos }) => {
|
||||
let node = initialNode;
|
||||
const wrap = document.createElement('div');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
head.contentEditable = 'false';
|
||||
|
||||
const langEl = document.createElement('span');
|
||||
langEl.className = 'md-codeblock__lang';
|
||||
|
||||
const metaEl = document.createElement('span');
|
||||
metaEl.className = 'md-codeblock__editor-meta';
|
||||
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'md-codeblock__actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'md-codeblock__copy';
|
||||
copyBtn.textContent = '复制';
|
||||
|
||||
actions.append(metaEl, copyBtn);
|
||||
head.append(langEl, actions);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
pre.appendChild(code);
|
||||
wrap.append(head, pre);
|
||||
|
||||
const sync = (n: ProseMirrorNode) => {
|
||||
applyEditorChrome({ wrap, langEl, metaEl, pre, code, node: n });
|
||||
};
|
||||
|
||||
copyBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||
copyBtn.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = typeof getPos === 'function' ? getPos() : null;
|
||||
const current = typeof pos === 'number' ? editor.state.doc.nodeAt(pos) : null;
|
||||
const text = current?.textContent ?? code.textContent ?? '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
});
|
||||
|
||||
sync(node);
|
||||
|
||||
return {
|
||||
dom: wrap,
|
||||
contentDOM: code,
|
||||
update: (updated) => {
|
||||
if (updated.type.name !== 'codeBlock') return false;
|
||||
node = updated;
|
||||
sync(updated);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const parentPlugins = this.parent?.() || [];
|
||||
return [
|
||||
...parentPlugins,
|
||||
createCodeBlockHighlightPlugin(),
|
||||
// 代码块内粘贴:强制纯文本,保留换行(避免 HTML 分块把换行吃掉)
|
||||
new Plugin({
|
||||
key: new PluginKey('articleCodeBlockPaste'),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const { state } = view;
|
||||
if (state.selection.$from.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (text == null) return false;
|
||||
event.preventDefault();
|
||||
const normalized = text.replace(/\r\n?/g, '\n');
|
||||
const tr = state.tr;
|
||||
if (!state.selection.empty) {
|
||||
tr.deleteSelection();
|
||||
}
|
||||
tr.insertText(normalized);
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
setArticleCodeBlock: (attrs) => ({ commands, editor }) => {
|
||||
const next = {
|
||||
language: attrs.language || null,
|
||||
lineNumbers: Boolean(attrs.lineNumbers),
|
||||
collapsed: Boolean(attrs.collapsed),
|
||||
};
|
||||
if (editor.isActive(this.name)) {
|
||||
return commands.updateAttributes(this.name, next);
|
||||
}
|
||||
return commands.setNode(this.name, next);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { mergeAttributes } from '@tiptap/core';
|
||||
|
||||
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
|
||||
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
articleImage: {
|
||||
setImageDisplay: (display: ImageDisplay) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章图片:在 TipTap Image 上增加 data-display,
|
||||
* 支持通栏 / 左绕排 / 右绕排。
|
||||
*/
|
||||
export const ArticleImage = Image.extend({
|
||||
name: 'image',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
display: {
|
||||
default: 'default' satisfies ImageDisplay,
|
||||
parseHTML: (el) =>
|
||||
(el.getAttribute('data-display') as ImageDisplay) || 'default',
|
||||
renderHTML: (attrs) => {
|
||||
const display = (attrs.display as ImageDisplay) || 'default';
|
||||
if (display === 'default') return {};
|
||||
return {
|
||||
'data-display': display,
|
||||
class: `article-img article-img--${display}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const display = (HTMLAttributes['data-display'] as ImageDisplay) || 'default';
|
||||
const cls = [
|
||||
HTMLAttributes.class,
|
||||
'article-img',
|
||||
display !== 'default' ? `article-img--${display}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return [
|
||||
'img',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: cls || undefined,
|
||||
draggable: false,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
setImageDisplay: (display) => ({ commands }) =>
|
||||
commands.updateAttributes(this.name, { display }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialUrl?: string;
|
||||
onConfirm: (url: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** 文章编辑器链接输入弹窗 */
|
||||
export function ArticleLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialUrl = '',
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setUrl(initialUrl || 'https://');
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(url.trim());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-link-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>插入链接</DialogTitle>
|
||||
<DialogDescription>输入完整 URL,留空并确认可移除已有链接。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://"
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter className="article-link-dialog__footer">
|
||||
{onRemove && initialUrl ? (
|
||||
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||
移除链接
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export interface TableInsertOptions {
|
||||
rows: number;
|
||||
cols: number;
|
||||
withHeaderRow: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS: TableInsertOptions = {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
withHeaderRow: true,
|
||||
};
|
||||
|
||||
const MIN_SIZE = 1;
|
||||
const MAX_ROWS = 20;
|
||||
const MAX_COLS = 10;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 光标已在表格内时显示「删除表格」 */
|
||||
editing?: boolean;
|
||||
onConfirm: (opts: TableInsertOptions) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number, fallback: number): number {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
/** 文章编辑器:插入表格(行列与表头) */
|
||||
export function ArticleTableDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing = false,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [rows, setRows] = useState(String(DEFAULT_OPTS.rows));
|
||||
const [cols, setCols] = useState(String(DEFAULT_OPTS.cols));
|
||||
const [withHeaderRow, setWithHeaderRow] = useState(DEFAULT_OPTS.withHeaderRow);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRows(String(DEFAULT_OPTS.rows));
|
||||
setCols(String(DEFAULT_OPTS.cols));
|
||||
setWithHeaderRow(DEFAULT_OPTS.withHeaderRow);
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm({
|
||||
rows: clampInt(rows, MIN_SIZE, MAX_ROWS, DEFAULT_OPTS.rows),
|
||||
cols: clampInt(cols, MIN_SIZE, MAX_COLS, DEFAULT_OPTS.cols),
|
||||
withHeaderRow,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-table-dialog sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '表格' : '插入表格'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? '可再插入一张新表,或删除当前表格。也可用工具栏增删行列。'
|
||||
: '选择行数与列数。源码模式会写入 GFM(GitHub 风格 Markdown)管道表。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="article-table-dialog__body">
|
||||
<div className="article-table-dialog__grid">
|
||||
<div className="article-table-dialog__field">
|
||||
<Label htmlFor="article-table-rows">行数</Label>
|
||||
<Input
|
||||
id="article-table-rows"
|
||||
type="number"
|
||||
min={MIN_SIZE}
|
||||
max={MAX_ROWS}
|
||||
value={rows}
|
||||
onChange={e => setRows(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="article-table-dialog__field">
|
||||
<Label htmlFor="article-table-cols">列数</Label>
|
||||
<Input
|
||||
id="article-table-cols"
|
||||
type="number"
|
||||
min={MIN_SIZE}
|
||||
max={MAX_COLS}
|
||||
value={cols}
|
||||
onChange={e => setCols(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-table-dialog__toggle">
|
||||
<div className="article-table-dialog__toggle-text">
|
||||
<Label htmlFor="article-table-header">首行为表头</Label>
|
||||
<p>表头单元格使用加粗样式</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="article-table-header"
|
||||
checked={withHeaderRow}
|
||||
onCheckedChange={setWithHeaderRow}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="article-table-dialog__footer">
|
||||
{editing && onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="article-table-dialog__remove"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
删除表格
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
插入
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { Node as ProseMirrorNode, NodeType } from '@tiptap/pm/model';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
|
||||
function isFloatImage(node: ProseMirrorNode): boolean {
|
||||
if (node.type.name !== 'image') return false;
|
||||
const display = node.attrs.display as string | undefined;
|
||||
return display === 'float-left' || display === 'float-right';
|
||||
}
|
||||
|
||||
function isBlankParagraph(node: ProseMirrorNode): boolean {
|
||||
if (node.type.name !== 'paragraph') return false;
|
||||
if (node.content.size === 0) return true;
|
||||
let blank = true;
|
||||
node.forEach(child => {
|
||||
if (child.type.name === 'hardBreak') return;
|
||||
if (child.isText && !(child.text || '').replace(/\u00a0/g, ' ').trim()) return;
|
||||
blank = false;
|
||||
});
|
||||
return blank;
|
||||
}
|
||||
|
||||
function isHardClearBlock(node: ProseMirrorNode): boolean {
|
||||
const name = node.type.name;
|
||||
if (name === 'image') return !isFloatImage(node);
|
||||
return name === 'imageGroup'
|
||||
|| name === 'heading'
|
||||
|| name === 'horizontalRule'
|
||||
|| name === 'codeBlock'
|
||||
|| name === 'blockquote'
|
||||
|| name === 'table'
|
||||
|| name === 'bulletList'
|
||||
|| name === 'orderedList';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算顶层块是否应带 clearFloat。
|
||||
* - 双空行后自动打开
|
||||
* - 一旦打开,只要仍在绕排图之后就保持(避免源码往返丢空段后失效)
|
||||
*/
|
||||
function computeClearFloatFlags(doc: ProseMirrorNode): boolean[] {
|
||||
const flags: boolean[] = [];
|
||||
let seenFloat = false;
|
||||
let blankRun = 0;
|
||||
|
||||
doc.forEach(node => {
|
||||
if (isFloatImage(node)) {
|
||||
seenFloat = true;
|
||||
blankRun = 0;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHardClearBlock(node)) {
|
||||
seenFloat = false;
|
||||
blankRun = 0;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seenFloat) {
|
||||
flags.push(false);
|
||||
blankRun = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBlankParagraph(node)) {
|
||||
blankRun += 1;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const already = Boolean(node.attrs.clearFloat);
|
||||
flags.push(blankRun >= 2 || already);
|
||||
blankRun = 0;
|
||||
});
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function syncClearFloatAttrs(doc: ProseMirrorNode, paragraphType: NodeType, tr: Transaction): boolean {
|
||||
const flags = computeClearFloatFlags(doc);
|
||||
let modified = false;
|
||||
let index = 0;
|
||||
|
||||
doc.forEach((node, offset) => {
|
||||
const should = flags[index] ?? false;
|
||||
index += 1;
|
||||
if (node.type !== paragraphType) return;
|
||||
const current = Boolean(node.attrs.clearFloat);
|
||||
if (current === should) return;
|
||||
tr.setNodeMarkup(offset, undefined, { ...node.attrs, clearFloat: should });
|
||||
modified = true;
|
||||
});
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/** 段落:支持 data-clear-float,源码往返可保留「写到绕排图下方」 */
|
||||
export const ClearFloatParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
clearFloat: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.hasAttribute('data-clear-float'),
|
||||
renderHTML: (attrs) => (
|
||||
attrs.clearFloat
|
||||
? { 'data-clear-float': '', class: 'article-clear-float' }
|
||||
: {}
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const syncKey = new PluginKey('clearFloatSync');
|
||||
|
||||
/** 根据双空行自动写入/保持段落 clearFloat 属性 */
|
||||
export const ClearFloatSync = Extension.create({
|
||||
name: 'clearFloatSync',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: syncKey,
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
if (!transactions.some(tr => tr.docChanged)) return null;
|
||||
if (transactions.some(tr => tr.getMeta(syncKey))) return null;
|
||||
|
||||
const paragraphType = newState.schema.nodes.paragraph;
|
||||
if (!paragraphType) return null;
|
||||
|
||||
const tr = newState.tr;
|
||||
if (!syncClearFloatAttrs(newState.doc, paragraphType, tr)) return null;
|
||||
tr.setMeta(syncKey, true);
|
||||
return tr;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,371 +0,0 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { Columns2, Columns3, LayoutGrid, Plus, Ungroup } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export type ImageGroupLayout = 'cols-2' | 'cols-3' | 'cols-4';
|
||||
|
||||
const LAYOUTS: { key: ImageGroupLayout; label: string; icon: typeof Columns2; hint: string }[] = [
|
||||
{ key: 'cols-2', label: '两列', icon: Columns2, hint: '并排两张' },
|
||||
{ key: 'cols-3', label: '三列', icon: Columns3, hint: '并排三张' },
|
||||
{ key: 'cols-4', label: '四列', icon: LayoutGrid, hint: '四宫格' },
|
||||
];
|
||||
|
||||
/** 按张数推荐默认布局 */
|
||||
export function suggestImageGroupLayout(count: number): ImageGroupLayout {
|
||||
if (count >= 4) return 'cols-4';
|
||||
if (count === 3) return 'cols-3';
|
||||
return 'cols-2';
|
||||
}
|
||||
|
||||
function findImageGroupDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string } };
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'imageGroup') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 空段落可夹在连续图片之间,合并时一并吃掉 */
|
||||
function isEmptyParagraph(node: ProseMirrorNode): boolean {
|
||||
return node.type.name === 'paragraph' && node.content.size === 0;
|
||||
}
|
||||
|
||||
/** 定位一张可作为合并起点的图片位置 */
|
||||
function findAnchorImagePos(state: EditorState): number | null {
|
||||
const { selection, doc } = state;
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return selection.from;
|
||||
}
|
||||
|
||||
const { $from, from, to } = selection;
|
||||
if (findImageGroupDepth($from) >= 0) return null;
|
||||
|
||||
let firstImagePos: number | null = null;
|
||||
doc.nodesBetween(from, Math.max(to, from + 1), (node, pos) => {
|
||||
if (node.type.name === 'image' && firstImagePos == null) {
|
||||
firstImagePos = pos;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (firstImagePos != null) return firstImagePos;
|
||||
|
||||
if ($from.nodeBefore?.type.name === 'image') {
|
||||
return $from.pos - $from.nodeBefore.nodeSize;
|
||||
}
|
||||
if ($from.nodeAfter?.type.name === 'image') {
|
||||
return $from.pos;
|
||||
}
|
||||
|
||||
// 光标在图片之间的段落时:沿祖先层级找相邻图片块
|
||||
for (let depth = $from.depth; depth >= 1; depth -= 1) {
|
||||
const parent = $from.node(depth);
|
||||
const index = $from.index(depth);
|
||||
|
||||
for (let i = index - 1; i >= 0; i -= 1) {
|
||||
const n = parent.child(i);
|
||||
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
|
||||
if (!isEmptyParagraph(n)) break;
|
||||
}
|
||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||
const n = parent.child(i);
|
||||
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
|
||||
if (!isEmptyParagraph(n)) break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以某张图为锚点,向两侧扩展「连续图片块」
|
||||
* (允许中间夹空段落;富文本难以框选多张 atom 图片)
|
||||
*/
|
||||
function collectConsecutiveImageRun(
|
||||
state: EditorState,
|
||||
imagePos: number,
|
||||
): { from: number; to: number; images: ProseMirrorNode[] } | null {
|
||||
const node = state.doc.nodeAt(imagePos);
|
||||
if (!node || node.type.name !== 'image') return null;
|
||||
|
||||
const $pos = state.doc.resolve(imagePos);
|
||||
const parent = $pos.parent;
|
||||
const index = $pos.index();
|
||||
if (parent.child(index) !== node) return null;
|
||||
|
||||
let start = index;
|
||||
while (start > 0) {
|
||||
const prev = parent.child(start - 1);
|
||||
if (prev.type.name === 'image' || isEmptyParagraph(prev)) start -= 1;
|
||||
else break;
|
||||
}
|
||||
while (start < index && isEmptyParagraph(parent.child(start))) start += 1;
|
||||
|
||||
let end = index;
|
||||
while (end < parent.childCount - 1) {
|
||||
const next = parent.child(end + 1);
|
||||
if (next.type.name === 'image' || isEmptyParagraph(next)) end += 1;
|
||||
else break;
|
||||
}
|
||||
while (end > index && isEmptyParagraph(parent.child(end))) end -= 1;
|
||||
|
||||
const images: ProseMirrorNode[] = [];
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
const child = parent.child(i);
|
||||
if (child.type.name === 'image') images.push(child);
|
||||
}
|
||||
if (images.length < 2) return null;
|
||||
|
||||
let from = $pos.start();
|
||||
for (let i = 0; i < start; i += 1) from += parent.child(i).nodeSize;
|
||||
let to = from;
|
||||
for (let i = start; i <= end; i += 1) to += parent.child(i).nodeSize;
|
||||
|
||||
return { from, to, images };
|
||||
}
|
||||
|
||||
function ImageGroupView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const layout = (node.attrs.layout as ImageGroupLayout) || 'cols-2';
|
||||
const count = node.childCount;
|
||||
|
||||
const setLayout = (next: ImageGroupLayout) => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().setImageGroupLayout(next).run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: next });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const unwrap = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapImageGroup().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
const files = [...(input.files ?? [])];
|
||||
if (!files.length) return;
|
||||
const urls: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
urls.push(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
}
|
||||
if (!urls.length) return;
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch, state }) => {
|
||||
const imageType = state.schema.nodes.image;
|
||||
if (!imageType || !dispatch) return false;
|
||||
let cur = pos + node.nodeSize - 1;
|
||||
for (const src of urls) {
|
||||
const img = imageType.create({ src });
|
||||
tr.insert(cur, img);
|
||||
cur += img.nodeSize;
|
||||
}
|
||||
const nextLayout = suggestImageGroupLayout(count + urls.length);
|
||||
tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: nextLayout });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`image-group image-group--${layout}${selected ? ' image-group--selected' : ''}`}
|
||||
data-image-group=""
|
||||
data-layout={layout}
|
||||
>
|
||||
<div className="image-group__toolbar" contentEditable={false}>
|
||||
<span className="image-group__toolbar-label">图组 · {count} 张</span>
|
||||
<div className="image-group__toolbar-actions">
|
||||
{LAYOUTS.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`image-group__layout-btn${layout === item.key ? ' is-active' : ''}`}
|
||||
title={item.hint}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setLayout(item.key)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="image-group__layout-btn"
|
||||
title="向本组追加图片"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={addImage}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>添加</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="image-group__layout-btn"
|
||||
title="拆开为单独图片"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={unwrap}
|
||||
>
|
||||
<Ungroup size={14} />
|
||||
<span>拆开</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="image-group__grid" as="div" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imageGroup: {
|
||||
insertImageGroup: (srcs: string[], layout?: ImageGroupLayout) => ReturnType;
|
||||
setImageGroupLayout: (layout: ImageGroupLayout) => ReturnType;
|
||||
unwrapImageGroup: () => ReturnType;
|
||||
wrapImagesInGroup: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 图组:多图并排 / 宫格布局 */
|
||||
export const ImageGroup = Node.create({
|
||||
name: 'imageGroup',
|
||||
group: 'block',
|
||||
content: 'image+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
layout: {
|
||||
default: 'cols-2' satisfies ImageGroupLayout,
|
||||
parseHTML: (el) => (el.getAttribute('data-layout') as ImageGroupLayout) || 'cols-2',
|
||||
renderHTML: (attrs) => ({ 'data-layout': attrs.layout || 'cols-2' }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-image-group]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const layout = HTMLAttributes['data-layout'] || 'cols-2';
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-image-group': '',
|
||||
'data-layout': layout,
|
||||
class: `image-group image-group--${layout}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageGroupView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageGroup: (srcs, layout) => ({ chain }) => {
|
||||
if (!srcs.length) return false;
|
||||
const nextLayout = layout || suggestImageGroupLayout(srcs.length);
|
||||
return chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
attrs: { layout: nextLayout },
|
||||
content: srcs.map(src => ({ type: 'image', attrs: { src } })),
|
||||
})
|
||||
.run();
|
||||
},
|
||||
|
||||
setImageGroupLayout: (layout) => ({ commands }) =>
|
||||
commands.updateAttributes(this.name, { layout }),
|
||||
|
||||
unwrapImageGroup: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findImageGroupDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 将光标/选区附近的连续图片包成图组。
|
||||
* TipTap 图片是 atom,无法像文本那样拖选多张,因此自动扩展相邻图片。
|
||||
*/
|
||||
wrapImagesInGroup: () => ({ tr, state, dispatch }) => {
|
||||
const anchor = findAnchorImagePos(state);
|
||||
if (anchor == null) return false;
|
||||
const run = collectConsecutiveImageRun(state, anchor);
|
||||
if (!run) return false;
|
||||
|
||||
const groupType = state.schema.nodes.imageGroup;
|
||||
if (!groupType) return false;
|
||||
|
||||
const group = groupType.create(
|
||||
{ layout: suggestImageGroupLayout(run.images.length) },
|
||||
run.images,
|
||||
);
|
||||
if (dispatch) {
|
||||
tr.replaceWith(run.from, run.to, group);
|
||||
// 选中新建图组,便于立刻改列数
|
||||
tr.setSelection(NodeSelection.create(tr.doc, run.from));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的登录可见节点深度 */
|
||||
function findMembersOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 登录可见区块是否无实质文字 */
|
||||
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isMembersOnlyEmpty(node);
|
||||
|
||||
/** 按 NodeView 自身位置删除,不依赖光标是否仍在块内 */
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removeMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-members-only__badge" contentEditable={false}>
|
||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||
<LockKeyhole size={12} />
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
<div className="post-members-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__remove-btn"
|
||||
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
membersOnly: {
|
||||
insertMembersOnly: () => ReturnType;
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
removeMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 自定义节点:登录可见内容区块 */
|
||||
export const MembersOnly = Node.create({
|
||||
name: 'membersOnly',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'members-only' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳
|
||||
return ['members-only', mergeAttributes({ 'data-gate': 'login' }, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MembersOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// 空区块内 Backspace / Delete:整块删除
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) {
|
||||
// 有内容时:在区块首字位置再按 Backspace 则解除包裹(与常见编辑器一致)
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const start = $from.start(depth);
|
||||
if ($from.pos !== start) return false;
|
||||
return editor.commands.unwrapMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) return false;
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const parent = $from.parent;
|
||||
const atBlockEnd = $from.parentOffset === parent.content.size;
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
|
||||
const membersNode = $from.node(depth);
|
||||
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
|
||||
return editor.commands.removeMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('membersOnly')) return false;
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertMembersOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { from, to, empty } = state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const slice = state.doc.slice(from, to);
|
||||
if (!slice.content.size) return false;
|
||||
|
||||
const node = state.schema.nodes.membersOnly.create(null, slice.content);
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(from, to, node);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
exitMembersOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
unwrapMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
|
||||
// 空区块:直接删除,避免留下空段落套壳
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { Coins, Trash2 } from 'lucide-react';
|
||||
|
||||
function findPointsOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'pointsOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isPointsOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
function PointsOnlyView({ selected, editor, node, getPos, updateAttributes }: NodeViewProps) {
|
||||
const empty = isPointsOnlyEmpty(node);
|
||||
const cost = Number(node.attrs.cost) || 10;
|
||||
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removePointsOnly().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
}).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`post-points-only post-points-only--visible editor-points-only${selected ? ' editor-points-only--selected' : ''}${empty ? ' editor-points-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-points-only__badge" contentEditable={false}>
|
||||
<span className="post-points-only__badge-icon" aria-hidden="true">
|
||||
<Coins size={12} />
|
||||
</span>
|
||||
<span>积分可见</span>
|
||||
<label className="post-points-only__cost">
|
||||
价格
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={cost}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onChange={e => {
|
||||
const v = Math.max(1, Math.min(9999, Number(e.target.value) || 1));
|
||||
updateAttributes({ cost: v });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="post-points-only__badge-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="post-points-only__remove-btn"
|
||||
title="删除积分可见区块"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-points-only__body" data-placeholder="此处内容需积分解锁后可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
pointsOnly: {
|
||||
insertPointsOnly: (cost?: number) => ReturnType;
|
||||
wrapPointsOnly: (cost?: number) => ReturnType;
|
||||
exitPointsOnly: () => ReturnType;
|
||||
removePointsOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap:积分可见内容区块 */
|
||||
export const PointsOnly = Node.create({
|
||||
name: 'pointsOnly',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
cost: {
|
||||
default: 10,
|
||||
parseHTML: el => {
|
||||
const v = Number(el.getAttribute('data-cost'));
|
||||
return v > 0 ? v : 10;
|
||||
},
|
||||
renderHTML: attrs => ({ 'data-cost': String(attrs.cost || 10) }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'points-only' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['points-only', mergeAttributes({ 'data-gate': 'points' }, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PointsOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
const depth = findPointsOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const node = $from.node(depth);
|
||||
if (!isPointsOnlyEmpty(node)) {
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
if ($from.pos !== $from.start(depth)) return false;
|
||||
}
|
||||
return editor.commands.removePointsOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
const depth = findPointsOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
if (!isPointsOnlyEmpty($from.node(depth))) return false;
|
||||
return editor.commands.removePointsOnly();
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
const depth = findPointsOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const parent = $from.parent;
|
||||
if ($from.parentOffset !== parent.content.size || parent.textContent.trim().length > 0) return false;
|
||||
const node = $from.node(depth);
|
||||
if (isPointsOnlyEmpty(node) && node.childCount <= 1) {
|
||||
return editor.commands.removePointsOnly();
|
||||
}
|
||||
return editor.commands.exitPointsOnly();
|
||||
},
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('pointsOnly')) return false;
|
||||
return editor.commands.exitPointsOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertPointsOnly: (cost = 10) => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
attrs: { cost },
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
wrapPointsOnly: (cost = 10) => ({ tr, state, dispatch }) => {
|
||||
const { from, to, empty } = state.selection;
|
||||
if (empty) return false;
|
||||
const slice = state.doc.slice(from, to);
|
||||
if (!slice.content.size) return false;
|
||||
const node = state.schema.nodes.pointsOnly.create({ cost }, slice.content);
|
||||
if (dispatch) tr.replaceRangeWith(from, to, node);
|
||||
return true;
|
||||
},
|
||||
|
||||
exitPointsOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findPointsOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
removePointsOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findPointsOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { MessageSquareLock, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的回复可见节点深度 */
|
||||
function findReplyOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'replyOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 回复可见区块是否无实质文字 */
|
||||
function isReplyOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「回复可见」区块视图 */
|
||||
function ReplyOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isReplyOnlyEmpty(node);
|
||||
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removeReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isReplyOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`post-reply-only post-reply-only--visible editor-reply-only${selected ? ' editor-reply-only--selected' : ''}${empty ? ' editor-reply-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-reply-only__badge" contentEditable={false}>
|
||||
<span className="post-reply-only__badge-icon" aria-hidden="true">
|
||||
<MessageSquareLock size={12} />
|
||||
</span>
|
||||
<span>回复可见</span>
|
||||
<div className="post-reply-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-reply-only__unwrap-btn"
|
||||
title="取消回复可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-reply-only__remove-btn"
|
||||
title={empty ? '删除空的回复可见区块' : '删除整个回复可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-reply-only__body" data-placeholder="此处内容需回复后可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
replyOnly: {
|
||||
insertReplyOnly: () => ReturnType;
|
||||
wrapReplyOnly: () => ReturnType;
|
||||
exitReplyOnly: () => ReturnType;
|
||||
unwrapReplyOnly: () => ReturnType;
|
||||
removeReplyOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 自定义节点:回复后可见内容区块 */
|
||||
export const ReplyOnly = Node.create({
|
||||
name: 'replyOnly',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'reply-only' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳
|
||||
return ['reply-only', mergeAttributes({ 'data-gate': 'reply' }, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ReplyOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isReplyOnlyEmpty(node)) {
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const start = $from.start(depth);
|
||||
if ($from.pos !== start) return false;
|
||||
return editor.commands.unwrapReplyOnly();
|
||||
}
|
||||
|
||||
return editor.commands.removeReplyOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isReplyOnlyEmpty(node)) return false;
|
||||
|
||||
return editor.commands.removeReplyOnly();
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const parent = $from.parent;
|
||||
const atBlockEnd = $from.parentOffset === parent.content.size;
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
const replyNode = $from.node(depth);
|
||||
if (isReplyOnlyEmpty(replyNode) && replyNode.childCount <= 1) {
|
||||
return editor.commands.removeReplyOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitReplyOnly();
|
||||
},
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('replyOnly')) return false;
|
||||
return editor.commands.exitReplyOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertReplyOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
wrapReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { from, to, empty } = state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const slice = state.doc.slice(from, to);
|
||||
if (!slice.content.size) return false;
|
||||
|
||||
const node = state.schema.nodes.replyOnly.create(null, slice.content);
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(from, to, node);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
exitReplyOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
unwrapReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
|
||||
if (isReplyOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user