Compare commits
118 Commits
99ae0ccdaa
...
rebuild/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 20aee9c566 | |||
| 45cc864971 | |||
| 3c6d8cb891 | |||
| 2392768868 | |||
| 3702e3d1f2 | |||
| 230006a905 | |||
| ba39376a0e | |||
| 1ce0f123fa | |||
| c99238c1e7 | |||
| e71c77b5ca | |||
| 27132e89c2 | |||
| 86a317c862 | |||
| 52d328ffc3 | |||
| 711cd8b759 | |||
| de2f5e8be8 | |||
| 5c774f9a83 | |||
| f3f50d7e90 | |||
| 67bef6dc08 | |||
| 23e304ac53 | |||
| cad53942e8 | |||
| 76be8926f2 | |||
| 4d93e455f9 | |||
| c958dcfd80 | |||
| 84bfbd9a3e | |||
| 500e452c2f | |||
| 1c0f7ede55 | |||
| 2e9c42a34c | |||
| dff60dde6a | |||
| ea9e0058d4 | |||
| d5aa36416c | |||
| 2159280af5 | |||
| bc9a031890 | |||
| 0d81787125 | |||
| 68713c053b | |||
| 94823c0821 | |||
| b608ec85ba | |||
| 6048d71759 | |||
| 37d14a22d5 | |||
| a02d103cfe | |||
| 21c6233fc0 | |||
| 4ca5c17e2e | |||
| cf7a37eccc | |||
| 44eed97fe0 | |||
| 9e134916a4 | |||
| ba60a9b1c4 | |||
| 132ca5c82c | |||
| 185649db7c | |||
| 9d926750b2 | |||
| a91d917fa9 | |||
| 34f3d088ad | |||
| c6ce44f8b4 | |||
| 429956c594 | |||
| 16b6a18096 | |||
| 95c2154087 | |||
| 0abfb32194 | |||
| bcfd306643 | |||
| 335b708b04 | |||
| a83f8bd670 | |||
| 4dc3bbb13f | |||
| 8cfe31da61 | |||
| 7965768f3d | |||
| 012cac23de | |||
| d929bf96aa | |||
| cce9fb9ed8 | |||
| 9eefe24a33 | |||
| f559fc4c4b | |||
| 55b1831da2 | |||
| c6df703b96 | |||
| 7cc574f9a1 | |||
| 2208af7070 | |||
| df19752a1e | |||
| c07d1fd2bd | |||
| 4f85a86077 | |||
| f0650a9c39 | |||
| 4f42a2faff | |||
| 536707f812 | |||
| 1550ccb693 | |||
| 4042395e3e | |||
| 383440ed5b | |||
| 94f6a9666b | |||
| 10185178e2 | |||
| 0f83183620 | |||
| bb8d415eb7 | |||
| e6e7ed73e3 | |||
| 6b0a1d4281 | |||
| b075495540 | |||
| c05cc472cf | |||
| 322ac14055 | |||
| e8b28a37e4 | |||
| cc51c49272 | |||
| 5b2d9a4ea4 | |||
| e50047e1c8 | |||
| 860dffd547 | |||
| 2a34699f64 | |||
| b24f6c23ea | |||
| 3aca5c42c5 | |||
| 2c06eaccb2 | |||
| 61b47363e9 | |||
| dd9e185847 | |||
| 81f5d451bc | |||
| 48db333272 | |||
| 060b7707cb | |||
| 822eef96be | |||
| 9487c8ab02 | |||
| 962bb15298 | |||
| 55c256654b | |||
| e038d4f9e9 | |||
| f527c91c27 | |||
| 773639fd7c | |||
| 9767b54192 | |||
| 0eae3f8c47 | |||
| 57172eb053 | |||
| e4d1dd139e | |||
| b451703642 | |||
| 1d273066b0 | |||
| d0555de28e | |||
| 9230f7272d | |||
| b27289bbea |
36
.cursor/rules/build-scripts.mdc
Normal file
36
.cursor/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编译与构建脚本
|
||||||
|
|
||||||
|
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||||
|
|
||||||
|
## 运行编译(优先用封装命令,不要猜命令)
|
||||||
|
|
||||||
|
| 平台 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||||
|
| Linux / macOS | `make` 或 `make <target>` |
|
||||||
|
|
||||||
|
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||||
|
|
||||||
|
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||||
|
|
||||||
|
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||||
|
|
||||||
|
## 修改构建脚本时的约定
|
||||||
|
|
||||||
|
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||||
|
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||||
|
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||||
|
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||||
|
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||||
|
|
||||||
|
## 新增 target 检查清单
|
||||||
|
|
||||||
|
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||||
|
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||||
|
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||||
|
- [ ] README「快速开始」如需对外暴露再更新
|
||||||
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Git 与编辑器
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
|
||||||
|
# 运行时数据与本地配置
|
||||||
|
data/
|
||||||
|
app.ini
|
||||||
|
tmp-cookie.txt
|
||||||
|
|
||||||
|
# 编译产物与前端缓存
|
||||||
|
dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# 文档与截图(不参与镜像构建)
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# 计划文件
|
||||||
|
*.plan.md
|
||||||
17
.gitattributes
vendored
Normal file
17
.gitattributes
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 跨平台行尾:仓库内统一 LF,避免 Windows autocrlf 造成「假 modified」
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows 批处理在 cmd 下需要 CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# 二进制
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.exe binary
|
||||||
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: 缺陷报告
|
||||||
|
about: 报告一个可复现的问题
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 简要说明发生了什么
|
||||||
|
placeholder: 评论回复换行后,页面上显示为一行…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. 打开帖子详情页
|
||||||
|
2. 在评论框输入多行文字
|
||||||
|
3. 点击发送
|
||||||
|
4. 观察到换行丢失
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: 期望行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: 实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: 版本 / 提交
|
||||||
|
placeholder: main @ abc1234 或 v1.0.0
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 环境
|
||||||
|
placeholder: Windows 11 / Chrome 125 / Go 1.26
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: 截图或日志
|
||||||
|
description: 可粘贴图片或相关日志
|
||||||
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: 功能建议
|
||||||
|
about: 提出新功能或改进现有体验
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
欢迎提出想法!请先搜索已有 Issue,避免重复。
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 要解决什么问题?
|
||||||
|
description: 从用户场景出发描述痛点
|
||||||
|
placeholder: 管理员在帖子详情页缺少某某操作入口…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的方案
|
||||||
|
description: 你心目中的实现方式(可简略)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 备选方案
|
||||||
|
description: 如有其他思路可一并说明
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: 涉及模块
|
||||||
|
options:
|
||||||
|
- 前台 UI/UX
|
||||||
|
- 管理后台
|
||||||
|
- 评论系统
|
||||||
|
- 帖子 / 板块
|
||||||
|
- 认证 / 权限
|
||||||
|
- 部署 / 构建
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,13 +1,19 @@
|
|||||||
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
# 本地配置(保留 app.ini.example)
|
||||||
|
/app.ini
|
||||||
|
|
||||||
# 前端依赖与构建缓存
|
# 前端依赖与构建缓存
|
||||||
|
/node_modules/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
|
/.vite/
|
||||||
|
|
||||||
# Go 编译产物
|
# Go 编译产物
|
||||||
/dist/
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
|
/jiang13-*
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
tmp-cookie.txt
|
tmp-cookie.txt
|
||||||
@@ -15,6 +21,10 @@ tmp-cookie.txt
|
|||||||
# 编辑器 / OS
|
# 编辑器 / OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.trae/documents/
|
||||||
|
.trae/chat/
|
||||||
|
.trae/image/
|
||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
36
.trae/rules/build-scripts.mdc
Normal file
36
.trae/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编译与构建脚本
|
||||||
|
|
||||||
|
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||||
|
|
||||||
|
## 运行编译(优先用封装命令,不要猜命令)
|
||||||
|
|
||||||
|
| 平台 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||||
|
| Linux / macOS | `make` 或 `make <target>` |
|
||||||
|
|
||||||
|
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||||
|
|
||||||
|
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||||
|
|
||||||
|
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||||
|
|
||||||
|
## 修改构建脚本时的约定
|
||||||
|
|
||||||
|
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||||
|
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||||
|
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||||
|
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||||
|
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||||
|
|
||||||
|
## 新增 target 检查清单
|
||||||
|
|
||||||
|
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||||
|
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||||
|
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||||
|
- [ ] README「快速开始」如需对外暴露再更新
|
||||||
52
CONTRIBUTING.md
Normal file
52
CONTRIBUTING.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 参与贡献
|
||||||
|
|
||||||
|
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
**要求:** Go 1.26+、Node.js 18+
|
||||||
|
|
||||||
|
```bat
|
||||||
|
REM Windows:一键启动后端 + 前端热更新(请用 build.bat)
|
||||||
|
build.bat -Target dev
|
||||||
|
REM 浏览器访问 http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交规范
|
||||||
|
|
||||||
|
- 一个 PR 只做一件事,保持 diff 小而清晰
|
||||||
|
- 前端改动请确认浅色 / 暗色主题下都正常
|
||||||
|
- 涉及 UI 变更时,建议在 PR 中附上截图
|
||||||
|
|
||||||
|
## 完整构建
|
||||||
|
|
||||||
|
发布单二进制前需先构建前端并 embed:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build.bat REM Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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)。
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 姜十三论坛 — 多阶段构建:Node 前端(Vike SSR)+ Go API,双服务共用镜像
|
||||||
|
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||||
|
#
|
||||||
|
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||||
|
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||||
|
# docker build --build-arg IMAGE_PREFIX= ...
|
||||||
|
#
|
||||||
|
# 运行形态(docker compose 双容器,同一镜像两种角色):
|
||||||
|
# api —— Go 二进制,纯 API(:3001 内部,/data 数据卷)
|
||||||
|
# web —— Node 渲染服务器(对外 :3000:SSR + 静态资源 + API 反代)
|
||||||
|
# 镜像默认入口为 api 角色;web 角色在 compose 中以 node 用户覆盖 entrypoint。
|
||||||
|
|
||||||
|
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# ── Stage 1: 前端构建(Vike → dist/client 静态资源 + dist/server SSR 入口)──
|
||||||
|
FROM ${IMAGE_PREFIX}node:24-bookworm-slim AS frontend
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com \
|
||||||
|
&& npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0;仅 API,无内嵌前端)─────
|
||||||
|
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||||
|
ARG VERSION
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-o /out/jiang13 ./cmd/jiang13
|
||||||
|
|
||||||
|
# ── Stage 3: 运行镜像(Node 运行时承载 SSR 渲染与静态资源,附带 Go API 二进制)
|
||||||
|
FROM ${IMAGE_PREFIX}node:24-bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gosu ca-certificates tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/jiang13 /app/jiang13
|
||||||
|
COPY --from=frontend /src/frontend /app/frontend
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||||
|
|
||||||
|
# 以 root 启动,entrypoint 修正 /data 卷权限后 gosu 降权为 uid 1000(node 用户)
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||||
69
Makefile
69
Makefile
@@ -4,73 +4,108 @@
|
|||||||
APP_NAME := jiang13
|
APP_NAME := jiang13
|
||||||
MAIN_PKG := ./cmd/jiang13
|
MAIN_PKG := ./cmd/jiang13
|
||||||
BUILD_DIR := dist
|
BUILD_DIR := dist
|
||||||
VERSION := 1.0.0
|
DEV_DATA_DIR := dist/data
|
||||||
|
VERSION := 1.1.8
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||||
|
|
||||||
GO := go
|
GO := go
|
||||||
GOFLAGS := -trimpath
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
|
.PHONY: all build web-build build-windows build-linux build-darwin build-all clean run serve dev tidy help docker compose-up compose-down
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
frontend-build:
|
## 前端构建(Vike:产出 frontend/dist/client 静态资源 + dist/server SSR 入口)
|
||||||
cd frontend && npm install && npm run build
|
web-build:
|
||||||
|
cd frontend && (test -d node_modules || npm install) && npm run build
|
||||||
|
|
||||||
## 编译当前平台二进制
|
## 编译当前平台二进制(纯 Go SQLite,无需 CGO;Go 仅含 API,前端由 Node 渲染)
|
||||||
build: frontend-build
|
build:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||||
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||||
|
|
||||||
## Windows amd64
|
## Windows amd64
|
||||||
build-windows:
|
build-windows:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
||||||
|
|
||||||
## Linux amd64
|
## Linux amd64
|
||||||
build-linux:
|
build-linux:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||||
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||||
|
|
||||||
## macOS arm64 (Apple Silicon)
|
## macOS arm64 (Apple Silicon)
|
||||||
build-darwin:
|
build-darwin:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||||
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||||
|
|
||||||
## 跨平台全量编译
|
## 跨平台全量编译
|
||||||
build-all: build-windows build-linux build-darwin build
|
build-all:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||||
@echo "✓ 全平台编译完成"
|
@echo "✓ 全平台编译完成"
|
||||||
|
|
||||||
## 整理依赖
|
## 整理依赖
|
||||||
tidy:
|
tidy:
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|
||||||
## 本地运行(仅后端,使用已 embed 的前端)
|
## 本地运行(仅 Go API :3000,无前端页面;数据目录与 dist 二进制一致)
|
||||||
run:
|
run:
|
||||||
$(GO) run $(MAIN_PKG) --port 3000 --data ./data
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
|
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
|
||||||
|
|
||||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
## 生产模式本地运行(Node :3000 对外 SSR + Go API :3001)
|
||||||
|
serve: build web-build
|
||||||
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
|
@trap 'kill 0' INT; \
|
||||||
|
$(BUILD_DIR)/$(APP_NAME) --data $(DEV_DATA_DIR) --port 3001 & \
|
||||||
|
cd frontend && PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
|
||||||
|
|
||||||
|
## 前端热更新开发(Vike dev :5173 + Go API :3000,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
|
||||||
dev:
|
dev:
|
||||||
@echo "前端热更新: http://localhost:5173"
|
@echo "前端热更新: http://localhost:5173"
|
||||||
@echo "后端 API : http://localhost:3000"
|
@echo "后端 API : http://localhost:3000"
|
||||||
|
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
|
||||||
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
@trap 'kill 0' INT; \
|
@trap 'kill 0' INT; \
|
||||||
$(GO) run $(MAIN_PKG) --port 3000 --data ./data & \
|
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
|
||||||
cd frontend && (test -d node_modules || npm install) && npm run dev
|
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||||
|
|
||||||
## 清理编译产物
|
## 清理编译产物
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
|
## 构建 Docker 镜像
|
||||||
|
docker:
|
||||||
|
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||||
|
|
||||||
|
## Docker Compose 启动(web :3000 + api :3001 双容器)
|
||||||
|
compose-up:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
## Docker Compose 停止
|
||||||
|
compose-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "姜十三论坛编译命令:"
|
@echo "姜十三论坛编译命令:"
|
||||||
@echo " make build - 编译当前平台"
|
@echo " make build - 编译 Go API 二进制(当前平台)"
|
||||||
|
@echo " make web-build - 构建前端(Vike:client + server 产物)"
|
||||||
|
@echo " make serve - 生产模式本地运行(Node :3000 + Go API :3001)"
|
||||||
@echo " make build-windows - 编译 Windows"
|
@echo " make build-windows - 编译 Windows"
|
||||||
@echo " make build-linux - 编译 Linux"
|
@echo " make build-linux - 编译 Linux"
|
||||||
@echo " make build-darwin - 编译 macOS"
|
@echo " make build-darwin - 编译 macOS"
|
||||||
@echo " make build-all - 编译全部平台"
|
@echo " make build-all - 编译全部平台"
|
||||||
@echo " make run - 仅启动后端(:3000)"
|
@echo " make run - 仅启动 Go API(:3000)"
|
||||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||||
|
@echo " make docker - 构建 Docker 镜像"
|
||||||
|
@echo " make compose-up - Docker Compose 启动"
|
||||||
|
@echo " make compose-down - Docker Compose 停止"
|
||||||
|
|||||||
572
README.md
572
README.md
@@ -1,187 +1,489 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# 姜十三论坛 Jiang13 Forum
|
# 姜十三论坛 Jiang13 Forum
|
||||||
|
|
||||||
轻量自用论坛系统,面向小圈子内部交流。与 Gitea 相同,编译为**单个独立 Go 二进制**,前端静态资源 embed 内嵌,内置 SQLite,开箱即用。
|
**能聊 · 好看 · 好装**
|
||||||
|
|
||||||
## 项目目录结构
|
面向小圈子、团队与同好社群的轻量现代化论坛。
|
||||||
|
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
|
||||||
|
|
||||||
```
|
<br>
|
||||||
js13-forum/
|
|
||||||
├── cmd/jiang13/main.go # 程序入口
|
[](https://bbs.iioio.com/)
|
||||||
├── config/config.go # 命令行参数与配置
|
[](LICENSE)
|
||||||
├── model/
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
│ ├── models.go # GORM 模型定义
|
[](go.mod)
|
||||||
│ └── db.go # 数据库初始化与自动迁移
|
[](frontend/package.json)
|
||||||
├── service/ # 业务逻辑层
|
[](#)
|
||||||
│ ├── auth.go # 注册/登录/JWT
|
|
||||||
│ ├── user.go # 用户资料/头像
|
[在线演示](https://bbs.iioio.com/) ·
|
||||||
│ ├── board.go # 板块 CRUD
|
[快速开始](#-快速开始) ·
|
||||||
│ ├── post.go # 帖子/点赞/收藏
|
[界面预览](#-界面预览) ·
|
||||||
│ ├── comment.go # 楼层评论
|
[功能亮点](#-功能亮点) ·
|
||||||
│ ├── backup.go # SQLite 备份
|
[更新日志](docs/site-posts/changelog.md) ·
|
||||||
│ ├── ratelimit.go # 访问限流
|
[路线图](ROADMAP.md) ·
|
||||||
│ └── common.go # 敏感词过滤等
|
[参与贡献](CONTRIBUTING.md)
|
||||||
├── handler/
|
|
||||||
│ ├── handlers.go # 前台页面与 API
|
<br>
|
||||||
│ └── admin.go # 后台管理
|
|
||||||
├── middleware/auth.go # JWT 鉴权与限流中间件
|
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
|
||||||
├── router/router.go # 路由注册
|
|
||||||
├── embed_static/
|
<sub>浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云</sub>
|
||||||
│ ├── embed.go # go:embed 声明
|
|
||||||
│ ├── static/css/style.css # 内嵌 CSS
|
<br>
|
||||||
│ ├── static/js/app.js # 内嵌 JS
|
|
||||||
│ └── templates/ # 内嵌 HTML 模板
|
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||||
├── go.mod
|
> 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎提 Issue / PR 共建。
|
||||||
├── Makefile
|
|
||||||
└── README.md
|
</div>
|
||||||
```
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 它是什么
|
||||||
|
|
||||||
|
姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||||
|
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||||
|
| 项目配套社区 | 可与 Gitea 等通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||||
|
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)。
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||||
|
<br><b>浅色主题</b><br>
|
||||||
|
<sub>左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论</sub>
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||||
|
<br><b>暗色主题</b><br>
|
||||||
|
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
|
||||||
|
<br><b>帖子详情</b><br>
|
||||||
|
<sub>文章目录 · 标签 · 作者卡片 · 修订信息</sub>
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
||||||
|
<br><b>富文本渲染</b><br>
|
||||||
|
<sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
|
||||||
|
<br><b>移动端</b> — 板块快捷筛选 · Feed 排序 · 触控友好列表
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能亮点
|
||||||
|
|
||||||
|
### 界面与交互
|
||||||
|
|
||||||
|
| 特性 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 |
|
||||||
|
| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 |
|
||||||
|
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
|
||||||
|
| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 |
|
||||||
|
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||||
|
|
||||||
|
### 社区功能
|
||||||
|
|
||||||
|
- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员**
|
||||||
|
- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华
|
||||||
|
- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限
|
||||||
|
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
|
||||||
|
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
||||||
|
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||||
|
- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
|
||||||
|
|
||||||
|
### 部署体验
|
||||||
|
|
||||||
|
- **双服务架构** — Node(:3000 对外:SSR + 静态资源 + API 反代)+ Go(:3001 内部:纯 API),职责单一、可独立扩缩
|
||||||
|
- **服务端渲染** — 首页 / 板块 / 帖子详情由 Vike SSR 真渲染,SEO 友好、首屏直出;其余页面 SPA 接管
|
||||||
|
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||||
|
- **跨平台** — Windows / Linux / macOS 一键编译 Go API
|
||||||
|
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||||
|
- **Docker 双容器** — 同一镜像两种角色,`docker compose up` 一键起
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 编译
|
### 1. 编译
|
||||||
|
|
||||||
**Windows(推荐,无需 GNU Make):**
|
Go API 二进制(不含前端,Windows):
|
||||||
|
|
||||||
```powershell
|
```bat
|
||||||
# PowerShell
|
|
||||||
.\build.ps1
|
|
||||||
|
|
||||||
# 或双击 / cmd
|
|
||||||
build.bat
|
build.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux / macOS(需 GNU Make):**
|
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
**手动分步(全平台通用):**
|
**手动分步(全平台):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||||
|
```
|
||||||
|
|
||||||
|
前端构建(Vike,产出 `frontend/dist/client` + `dist/server`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run build
|
cd frontend && npm install && npm run build
|
||||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Windows 自带的 `make` 通常是 Embarcadero MAKE,**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make(`choco install make`)后再用 `make build`。
|
本地生产模式一键起双服务(Node :3000 对外 + Go API :3001):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make serve # Linux / macOS
|
||||||
|
build.bat -Target serve # Windows
|
||||||
|
```
|
||||||
|
|
||||||
跨平台编译:
|
跨平台编译:
|
||||||
|
|
||||||
```powershell
|
```bat
|
||||||
.\build.ps1 -Target build-windows
|
build.bat -Target build-windows
|
||||||
.\build.ps1 -Target build-linux
|
build.bat -Target build-linux
|
||||||
.\build.ps1 -Target build-all
|
build.bat -Target build-all
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动
|
### 2. Docker 部署(推荐)
|
||||||
|
|
||||||
|
**一键启动(本地构建):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
docker compose up -d --build
|
||||||
.\dist\jiang13.exe --port 3000 --data ./data
|
|
||||||
|
|
||||||
# Linux / macOS
|
|
||||||
./dist/jiang13 --port 3000 --data ./data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 首次使用
|
```bat
|
||||||
|
.\build.bat -Target compose-up
|
||||||
|
```
|
||||||
|
|
||||||
1. 浏览器打开 http://localhost:3000/register 注册账号
|
```bash
|
||||||
2. **第一个注册的用户自动成为管理员**,无需额外命令
|
make compose-up
|
||||||
3. 登录后可访问 http://localhost:3000/admin/dashboard 进入后台
|
```
|
||||||
|
|
||||||
## 启动参数
|
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||||
|
|
||||||
|
版本变更与升级说明见 **[更新日志](docs/site-posts/changelog.md)**。Docker 与 Windows / Linux 单文件共用同一版本号。
|
||||||
|
|
||||||
|
- Docker 标签:[hub.docker.com/r/hangzhang714128/jiang13-forum/tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags)
|
||||||
|
- 预编译包:[Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases)
|
||||||
|
|
||||||
|
**拉取已构建镜像(Docker Hub):**
|
||||||
|
|
||||||
|
同一镜像两种角色,建议直接用 `docker-compose.yml`(已含完整配置):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose 会启动两个容器:
|
||||||
|
|
||||||
|
| 容器 | 角色 | 端口 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `web` | Node 渲染服务器 | `3000` 对外 | Vike SSR + 静态资源 + API 反代(`GO_API_URL=http://api:3001`) |
|
||||||
|
| `api` | Go API | `3001` 内部 | 纯 API 服务,持有 `/data` 数据卷 |
|
||||||
|
|
||||||
|
**数据持久化:** `api` 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`,适配 1Panel 等面板挂载的目录。
|
||||||
|
|
||||||
|
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||||
|
|
||||||
|
**可选环境变量(容器编排):**
|
||||||
|
|
||||||
|
| 变量 | 容器 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GO_API_URL` | web | Go API 地址(默认 `http://127.0.0.1:3001`,Compose 中为 `http://api:3001`) |
|
||||||
|
| `PORT` | web | Node 渲染服务器监听端口(默认 `3000`) |
|
||||||
|
| `JIANG13_HTTP_PORT` | api | HTTP 端口(默认 `3000`,Compose 中为 `3001`) |
|
||||||
|
| `JIANG13_DATA` | api | 数据目录(默认 `/data`) |
|
||||||
|
| `JIANG13_JWT_SECRET` | api | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||||
|
| `JIANG13_CONFIG` | api | 配置文件路径 |
|
||||||
|
| `JIANG13_WORK_PATH` | api | 工作目录 |
|
||||||
|
| `JIANG13_COMMUNITY_HUB` | api | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||||
|
|
||||||
|
**健康检查:** Go API `GET /health`、Node 渲染服务器 `GET /healthz`,均返回 200,供 Docker / 负载均衡探活。
|
||||||
|
|
||||||
|
**发布镜像到 Docker Hub(手动):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login
|
||||||
|
.\build.bat -Target docker # Windows
|
||||||
|
# make docker # Linux/macOS
|
||||||
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VERSION=1.1.7 -t hangzhang714128/jiang13-forum:1.1.7 -t hangzhang714128/jiang13-forum:latest .
|
||||||
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
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. 用 Compose 编排(`docker-compose.yml`)或手动创建两个容器:`api`(Go,内部 `3001`,挂载 `/data`)与 `web`(Node,映射 `3000:3000`)
|
||||||
|
2. `web` 容器环境变量 `GO_API_URL` 指向 `api` 容器(如 `http://api:3001`)
|
||||||
|
3. 数据卷只需挂在 `api` 容器的 `/data`(镜像会自动修正目录权限)
|
||||||
|
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||||
|
|
||||||
|
### 3. 直接启动(二进制)
|
||||||
|
|
||||||
|
Go 二进制仅提供 API,完整站点需同时运行 Node 渲染服务器(构建前端后 `frontend/server/index.mjs`)。最简单的方式是用上面的 Docker Compose,或本地 `make serve` / `build.bat -Target serve` 一键起双进程。
|
||||||
|
|
||||||
|
如需手动部署二进制,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件:
|
||||||
|
|
||||||
|
| 文件 | 平台 |
|
||||||
|
|------|------|
|
||||||
|
| `jiang13-*-windows-amd64.exe` | Windows x64 |
|
||||||
|
| `jiang13-*-linux-amd64` | Linux x64 |
|
||||||
|
|
||||||
|
当前 **1.1.7:** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
|
||||||
|
两个进程一起跑(Go API :3001 + Node :3000 对外):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 启动 Go API
|
||||||
|
./jiang13 --port 3001 --data ./data
|
||||||
|
|
||||||
|
# 2) 另开终端,构建并启动 Node 渲染服务器
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\jiang13.exe --port 3001 --data .\data
|
||||||
|
# 另开 PowerShell:
|
||||||
|
cd frontend; npm install; npm run build
|
||||||
|
$env:PORT = '3000'; $env:GO_API_URL = 'http://127.0.0.1:3001'; npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 首次使用
|
||||||
|
|
||||||
|
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||||
|
2. **第一个注册的用户自动成为管理员**
|
||||||
|
3. 登录后访问 `http://localhost:3000/admin` 进入后台
|
||||||
|
|
||||||
|
### 配置文件(`app.ini`)
|
||||||
|
|
||||||
|
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[server]
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
DATA = data
|
||||||
|
|
||||||
|
[security]
|
||||||
|
JWT_SECRET =
|
||||||
|
```
|
||||||
|
|
||||||
|
完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
|
||||||
|
|
||||||
|
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
||||||
|
|
||||||
|
### 启动参数
|
||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
| 参数 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `--port` | 3000 | HTTP 监听端口 |
|
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
|
||||||
| `--data` | ./data | 数据目录(SQLite、上传、日志) |
|
| `--config` | `{work-path}/app.ini` | 配置文件路径 |
|
||||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则自动生成并持久化到 data/.jwt_secret) |
|
| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
|
||||||
|
| `--data` | (读配置 / `data`) | 数据目录 |
|
||||||
|
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||||
|
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||||
|
|
||||||
## 数据目录说明
|
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
|
||||||
|
|
||||||
```
|
### 5. 注册为系统服务(可选)
|
||||||
data/
|
|
||||||
├── jiang13.db # SQLite 主数据库
|
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||||
├── jiang13.log # 运行日志
|
|
||||||
├── filter_words.txt # 敏感词配置(可编辑)
|
**Ubuntu / Linux(systemd,需 root):**
|
||||||
├── .jwt_secret # JWT 密钥(自动生成)
|
|
||||||
├── uploads/avatars/ # 用户头像(运行时写入,非 embed)
|
```bash
|
||||||
└── jiang13_backup_*.db # 后台导出的备份文件
|
sudo mkdir -p /opt/jiang13
|
||||||
|
sudo cp jiang13 /opt/jiang13/
|
||||||
|
sudo /opt/jiang13/jiang13 --service install
|
||||||
|
sudo /opt/jiang13/jiang13 --service start
|
||||||
|
sudo systemctl enable jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
## 修改前端并重新打包(Gitea 同款流程)
|
**Windows(Windows Service,需管理员 PowerShell):**
|
||||||
|
|
||||||
1. 编辑 `embed_static/templates/` 下的 HTML 模板
|
```powershell
|
||||||
2. 编辑 `embed_static/static/css/` 或 `static/js/` 下的静态资源
|
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
||||||
3. 重新编译:`make build`
|
Copy-Item .\jiang13.exe C:\jiang13\
|
||||||
4. 替换旧二进制,重启服务
|
C:\jiang13\jiang13.exe --service install
|
||||||
|
C:\jiang13\jiang13.exe --service start
|
||||||
前端资源通过 `//go:embed` 编译进二进制,**运行时不需要单独的前端文件夹**。只有用户上传的头像保存在 `--data` 目录。
|
|
||||||
|
|
||||||
```go
|
|
||||||
// embed_static/embed.go
|
|
||||||
//go:embed static/*
|
|
||||||
var staticFS embed.FS
|
|
||||||
|
|
||||||
//go:embed templates/*
|
|
||||||
var templatesFS embed.FS
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能概览
|
> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
|
||||||
|
|
||||||
- 用户注册/登录(bcrypt + JWT Cookie)
|
---
|
||||||
- 普通用户 / 管理员两级权限
|
|
||||||
- 板块管理、发帖、富文本 HTML、标签、置顶
|
|
||||||
- 楼层式评论,支持回复指定楼层
|
|
||||||
- 点赞、收藏
|
|
||||||
- 管理员:删帖、删评论、禁言、SQLite 备份
|
|
||||||
- 内置敏感词过滤、发帖/评论限流
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**:Go 1.26 + Gin + GORM + SQLite
|
| 层级 | 技术 |
|
||||||
- **前端**:React 18 + Arco Design + TanStack Virtual(虚拟滚动)
|
|
||||||
- **打包**:Vite 构建 → `go:embed` 内嵌 SPA,与 Gitea 同款单二进制
|
|
||||||
|
|
||||||
## 前端开发(热更新 HMR)
|
|
||||||
|
|
||||||
日常改前端**不需要**重新 `npm run build` 或 `go build`,用 Vite 开发服务器即可秒级热更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 一键启动:Go 后端 + Vite 热更新(推荐)
|
|
||||||
.\build.ps1 -Target dev # Windows
|
|
||||||
make dev # Linux / macOS(需 GNU Make)
|
|
||||||
|
|
||||||
# 浏览器访问 http://localhost:5173
|
|
||||||
# API 自动代理到 http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
也可开两个终端分别启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 终端 1:仅后端
|
|
||||||
.\build.ps1 -Target run # 或 make run
|
|
||||||
|
|
||||||
# 终端 2:仅前端热更新
|
|
||||||
cd frontend && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**何时才需要完整构建:**
|
|
||||||
|
|
||||||
- 改 Go 代码、模板、embed 静态资源 → `go build` / `make build`
|
|
||||||
- 发布单二进制前 → `npm run build` + `make build`(前端产物写入 `embed_static/static/spa/` 后 embed 进二进制)
|
|
||||||
|
|
||||||
> 若直接访问 `:3000`,看到的是**上次 build 嵌入**的前端,不会有热更新。开发时请用 `:5173`。
|
|
||||||
|
|
||||||
### 前端特性(高密度 V2EX/NGA 风格)
|
|
||||||
|
|
||||||
| 特性 | 实现 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| 三栏布局 | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
| **后端** | Go 1.26 · Gin · GORM · SQLite(纯 API 服务) |
|
||||||
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 |
|
| **前端** | React 19 · Vike SSR · react-router · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||||
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 |
|
| **构建** | Vite 8(Rolldown)双构建:`dist/client` 静态资源 + `dist/server` SSR 入口 |
|
||||||
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 |
|
| **渲染** | Node 24 · Express + Vike `renderPage`(:3000 对外,API 反代至 Go :3001) |
|
||||||
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
|
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端开发
|
||||||
|
|
||||||
|
日常改前端不需要重新完整构建,Vike dev 支持秒级热更新(HMR,热模块替换):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build.bat -Target dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。首页 / 板块 / 帖子详情在 dev 下同样走 SSR(`vike dev`),与生产行为一致;其余页面为 SPA 壳。
|
||||||
|
|
||||||
|
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||||
|
|
||||||
|
**何时需要完整构建:**
|
||||||
|
|
||||||
|
- 修改 Go 代码 → `build.bat` / `make build`(仅编译 Go API)
|
||||||
|
- 验证生产 SSR / 发布部署 → `make serve`(构建前端 + 双进程起本地产)
|
||||||
|
- 更新 README 界面截图 → 见下方「更新截图」
|
||||||
|
|
||||||
|
> 开发时请访问 `:5173`;`:3000` 只有在生产模式(Node 渲染服务器)下才有页面。
|
||||||
|
|
||||||
|
### 更新截图
|
||||||
|
|
||||||
|
默认从演示站抓取到 `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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
jiang13-forum/
|
||||||
|
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||||
|
├── config/ # app.ini 与命令行配置
|
||||||
|
├── app.ini.example # 配置文件示例
|
||||||
|
├── Dockerfile # 多阶段构建(前端 + Go API 共用镜像,双容器运行)
|
||||||
|
├── docker-compose.yml # 双容器 Compose 部署(web :3000 + api :3001)
|
||||||
|
├── docker-entrypoint.sh # api 容器启动脚本(修正 /data 卷权限)
|
||||||
|
├── .dockerignore
|
||||||
|
├── model/ # GORM 模型与数据库迁移
|
||||||
|
├── service/ # 业务逻辑
|
||||||
|
├── handler/ # HTTP 处理器(API + /api/home-boot SSR 聚合端点)
|
||||||
|
├── middleware/ # JWT 鉴权等
|
||||||
|
├── router/ # 路由注册(仅 API)
|
||||||
|
├── frontend/
|
||||||
|
│ ├── pages/ # Vike 页面(feed=SSR 白名单 / spa=catch-all 壳 / _error)
|
||||||
|
│ ├── renderer/ # Vike 双端渲染入口(onRenderHtml / onRenderClient)
|
||||||
|
│ ├── server/ # Node 渲染服务器(express + renderPage + API 反代)
|
||||||
|
│ └── src/ # React 源码(含 src/ssr 服务端取数与作用域管理)
|
||||||
|
├── docs/screenshots/ # README 界面截图
|
||||||
|
├── ROADMAP.md # 路线图与已知问题
|
||||||
|
└── scripts/ # 开发辅助脚本(含截图)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据目录
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
|
├── jiang13.log # 运行日志
|
||||||
|
├── filter_words.txt # 敏感词配置
|
||||||
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||||
|
├── uploads/avatars/ # 用户头像
|
||||||
|
├── uploads/posts/ # 帖子正文图片
|
||||||
|
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发状态
|
||||||
|
|
||||||
|
项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**。
|
||||||
|
|
||||||
|
| 类型 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||||
|
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
|
||||||
|
| ✅ 管理后台 | React SPA:仪表盘、置顶 / 精华、禁言、系统设置 |
|
||||||
|
| 📋 计划中 | 通知动态优化、邮件提醒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参与贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT — 自用随意,欢迎修改。
|
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||||
|
|||||||
75
ROADMAP.md
Normal file
75
ROADMAP.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 路线图 ROADMAP
|
||||||
|
|
||||||
|
> 姜十三论坛仍在积极开发中,功能尚未完善。
|
||||||
|
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
||||||
|
|
||||||
|
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发状态概览
|
||||||
|
|
||||||
|
| 模块 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||||
|
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||||
|
| 评论系统 | ✅ | 换行显示已修复 |
|
||||||
|
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(管理后台配置 ROOT_URL 与 OAuth 应用) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 已知缺陷(Bug)
|
||||||
|
|
||||||
|
_当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 计划中(Planned)
|
||||||
|
|
||||||
|
| 优先级 | 功能 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||||
|
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 进行中(In Progress)
|
||||||
|
|
||||||
|
_当前无公开认领任务。_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成(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_
|
||||||
25
app.ini.example
Normal file
25
app.ini.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini)
|
||||||
|
; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
|
||||||
|
; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
|
||||||
|
; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。
|
||||||
|
|
||||||
|
[server]
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
||||||
|
DATA = data
|
||||||
|
|
||||||
|
[security]
|
||||||
|
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||||
|
JWT_SECRET =
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||||
|
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||||
|
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||||
|
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||||
|
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; [community]
|
||||||
|
; HUB = false
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM 姜十三论坛 Windows 快捷构建(双击或命令行均可)
|
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||||
exit /b %ERRORLEVEL%
|
exit /b %ERRORLEVEL%
|
||||||
|
|||||||
97
build.ps1
97
build.ps1
@@ -3,7 +3,7 @@
|
|||||||
# .\build.ps1 -Target build-windows
|
# .\build.ps1 -Target build-windows
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
|
[ValidateSet('build', 'web-build', 'frontend', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'tidy', 'run', 'serve', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||||
[string]$Target = 'build'
|
[string]$Target = 'build'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$AppName = 'jiang13'
|
$AppName = 'jiang13'
|
||||||
$MainPkg = './cmd/jiang13'
|
$MainPkg = './cmd/jiang13'
|
||||||
$BuildDir = 'dist'
|
$BuildDir = 'dist'
|
||||||
$Version = '1.0.0'
|
$DevDataDir = 'dist/data'
|
||||||
|
$Version = '1.1.8'
|
||||||
|
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||||
$Ldlags = "-s -w -X main.version=$Version"
|
$Ldlags = "-s -w -X main.version=$Version"
|
||||||
|
|
||||||
function Ensure-Dir($path) {
|
function Ensure-Dir($path) {
|
||||||
@@ -21,7 +23,7 @@ function Ensure-Dir($path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Build-Frontend {
|
function Build-Frontend {
|
||||||
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
|
Write-Host '[frontend] npm run build (Vike: client + server)...' -ForegroundColor Cyan
|
||||||
Push-Location frontend
|
Push-Location frontend
|
||||||
try {
|
try {
|
||||||
if (-not (Test-Path node_modules)) {
|
if (-not (Test-Path node_modules)) {
|
||||||
@@ -39,32 +41,51 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
|||||||
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
||||||
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
|
||||||
|
$prevCgo = $env:CGO_ENABLED
|
||||||
|
$env:CGO_ENABLED = '0'
|
||||||
|
|
||||||
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
||||||
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
||||||
$OutFile = "$OutFile.exe"
|
$OutFile = "$OutFile.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
$outPath = Join-Path $BuildDir $OutFile
|
$outPath = Join-Path $BuildDir $OutFile
|
||||||
Write-Host "[go] build -> $outPath" -ForegroundColor Cyan
|
Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||||
Write-Host "[ok] $outPath" -ForegroundColor Green
|
Write-Host "[ok] $outPath" -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
if ($null -eq $prevCgo) {
|
||||||
|
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
$env:CGO_ENABLED = $prevCgo
|
||||||
|
}
|
||||||
|
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ($Target) {
|
switch ($Target) {
|
||||||
'help' {
|
'help' {
|
||||||
Write-Host '.\build.ps1 build current platform'
|
Write-Host '.\build.ps1 build Go API binary (current platform)'
|
||||||
Write-Host '.\build.ps1 -Target frontend frontend only'
|
Write-Host '.\build.ps1 -Target web-build build frontend (Vike client + server)'
|
||||||
|
Write-Host '.\build.ps1 -Target serve production run: Node :3000 + Go API :3001'
|
||||||
Write-Host '.\build.ps1 -Target build-windows'
|
Write-Host '.\build.ps1 -Target build-windows'
|
||||||
Write-Host '.\build.ps1 -Target build-linux'
|
Write-Host '.\build.ps1 -Target build-linux'
|
||||||
Write-Host '.\build.ps1 -Target build-all'
|
Write-Host '.\build.ps1 -Target build-all'
|
||||||
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
Write-Host '.\build.ps1 -Target run Go API only (port 3000, no pages)'
|
||||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
Write-Host '.\build.ps1 -Target dev Vike HMR :5173 + Go API :3000 (recommended for frontend dev)'
|
||||||
Write-Host '.\build.ps1 -Target tidy'
|
Write-Host '.\build.ps1 -Target tidy'
|
||||||
Write-Host '.\build.ps1 -Target clean'
|
Write-Host '.\build.ps1 -Target clean'
|
||||||
|
Write-Host '.\build.ps1 -Target docker build Docker image'
|
||||||
|
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
|
||||||
|
Write-Host '.\build.ps1 -Target compose-down docker compose down'
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||||
}
|
}
|
||||||
|
'web-build' { Build-Frontend }
|
||||||
'frontend' { Build-Frontend }
|
'frontend' { Build-Frontend }
|
||||||
'tidy' { go mod tidy }
|
'tidy' { go mod tidy }
|
||||||
'clean' {
|
'clean' {
|
||||||
@@ -72,18 +93,45 @@ switch ($Target) {
|
|||||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
'run' {
|
'run' {
|
||||||
go run $MainPkg --port 3000 --data ./data
|
Ensure-Dir $DevDataDir
|
||||||
|
go run $MainPkg --data $DevDataDir
|
||||||
|
}
|
||||||
|
'serve' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile $AppName
|
||||||
|
Ensure-Dir $DevDataDir
|
||||||
|
$root = (Get-Location).Path
|
||||||
|
$goExe = Join-Path $root "$BuildDir\$AppName.exe"
|
||||||
|
Write-Host '[serve] 正在新窗口启动 Go API (:3001)...' -ForegroundColor Cyan
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
'-NoExit', '-Command',
|
||||||
|
"Set-Location '$root'; & '$goExe' --data '$DevDataDir' --port 3001"
|
||||||
|
) | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Write-Host '[serve] 启动 Node 渲染服务器 (:3000)...' -ForegroundColor Cyan
|
||||||
|
Push-Location frontend
|
||||||
|
try {
|
||||||
|
$env:PORT = '3000'
|
||||||
|
$env:GO_API_URL = 'http://127.0.0.1:3001'
|
||||||
|
npm start
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:PORT -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GO_API_URL -ErrorAction SilentlyContinue
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
}
|
}
|
||||||
'dev' {
|
'dev' {
|
||||||
$root = (Get-Location).Path
|
$root = (Get-Location).Path
|
||||||
|
Ensure-Dir $DevDataDir
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
|
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vike HMR)' -ForegroundColor Green
|
||||||
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
|
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
|
||||||
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
|
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
|
||||||
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
|
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||||
|
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
'-NoExit', '-Command',
|
'-NoExit', '-Command',
|
||||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data"
|
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
|
||||||
) | Out-Null
|
) | Out-Null
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Push-Location frontend
|
Push-Location frontend
|
||||||
@@ -95,26 +143,37 @@ switch ($Target) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
'build' {
|
'build' {
|
||||||
Build-Frontend
|
|
||||||
Build-Go -OutFile $AppName
|
Build-Go -OutFile $AppName
|
||||||
}
|
}
|
||||||
'build-windows' {
|
'build-windows' {
|
||||||
Build-Frontend
|
|
||||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
'build-linux' {
|
'build-linux' {
|
||||||
Build-Frontend
|
|
||||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
'build-darwin' {
|
'build-darwin' {
|
||||||
Build-Frontend
|
|
||||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
}
|
}
|
||||||
'build-all' {
|
'build-all' {
|
||||||
Build-Frontend
|
|
||||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
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 'docker compose up failed' }
|
||||||
|
Write-Host '[ok] compose started' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'compose-down' {
|
||||||
|
docker compose down
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
|
||||||
|
Write-Host '[ok] compose stopped' -ForegroundColor Green
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,48 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jiang13/forum/config"
|
"github.com/kardianos/service"
|
||||||
"github.com/jiang13/forum/model"
|
|
||||||
"github.com/jiang13/forum/router"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
appsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
appsvc.SetAppVersion(version)
|
||||||
cfg, err := config.Parse()
|
cfg, err := config.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("配置解析失败: %v", err)
|
log.Fatalf("配置解析失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日志同时输出到控制台和文件
|
svcCfg, err := buildServiceConfig(cfg)
|
||||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("打开日志文件失败: %v", err)
|
log.Fatalf("构建服务配置失败: %v", err)
|
||||||
}
|
|
||||||
defer logFile.Close()
|
|
||||||
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
|
||||||
|
|
||||||
log.Println("========================================")
|
|
||||||
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
|
||||||
log.Println("========================================")
|
|
||||||
|
|
||||||
// 初始化数据库
|
|
||||||
if err := model.InitDB(cfg.DBPath()); err != nil {
|
|
||||||
log.Fatalf("数据库初始化失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置路由
|
prg := &program{cfg: cfg}
|
||||||
engine, err := router.Setup(cfg)
|
svc, err := service.New(prg, svcCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("路由初始化失败: %v", err)
|
log.Fatalf("创建系统服务失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
if cfg.ServiceAction != "" {
|
||||||
srv := &http.Server{
|
if err := runServiceControl(svc, cfg.ServiceAction); err != nil {
|
||||||
Addr: addr,
|
fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err)
|
||||||
Handler: engine,
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优雅关机
|
// 交互终端或由服务管理器拉起时均走 Run:
|
||||||
go func() {
|
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
|
||||||
sigCh := make(chan os.Signal, 1)
|
if err := svc.Run(); err != nil {
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
log.Fatalf("运行失败: %v", err)
|
||||||
<-sigCh
|
|
||||||
log.Println("收到关机信号,正在优雅关闭...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("HTTP 服务关闭异常: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
|
||||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
|
||||||
log.Printf("数据目录: %s", cfg.DataDir)
|
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("HTTP 服务异常: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("姜十三论坛已安全退出")
|
|
||||||
}
|
}
|
||||||
|
|||||||
170
cmd/jiang13/program.go
Normal file
170
cmd/jiang13/program.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
forumsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
svcName = "jiang13"
|
||||||
|
svcDisplayName = "姜十三论坛"
|
||||||
|
svcDescription = "姜十三论坛 Jiang13 Forum — 轻量单二进制论坛服务"
|
||||||
|
)
|
||||||
|
|
||||||
|
// program 实现 kardianos/service.Interface,兼容 Windows Service 与 Linux systemd
|
||||||
|
type program struct {
|
||||||
|
cfg *config.Config
|
||||||
|
server *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *program) Start(s service.Service) error {
|
||||||
|
if err := p.setup(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("HTTP 服务异常: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *program) Stop(s service.Service) error {
|
||||||
|
log.Println("收到关机信号,正在优雅关闭...")
|
||||||
|
if p.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := p.server.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("HTTP 服务关闭异常: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Println("姜十三论坛已安全退出")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *program) setup() error {
|
||||||
|
cfg := p.cfg
|
||||||
|
|
||||||
|
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() {
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||||
|
} else {
|
||||||
|
log.SetOutput(logFile)
|
||||||
|
}
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||||
|
|
||||||
|
log.Println("========================================")
|
||||||
|
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||||
|
log.Printf(" 版本: %s", version)
|
||||||
|
log.Println("========================================")
|
||||||
|
|
||||||
|
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||||
|
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := forumsvc.BackfillModerationNotifyRefs(); err != nil {
|
||||||
|
log.Printf("待审通知关联字段回填警告: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||||
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := router.Setup(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("路由初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
p.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||||
|
if cfg.DevMode {
|
||||||
|
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
|
||||||
|
} else {
|
||||||
|
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
|
||||||
|
}
|
||||||
|
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||||
|
log.Printf("配置文件: %s", cfg.ConfigFile)
|
||||||
|
log.Printf("数据目录: %s", cfg.DataDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
|
||||||
|
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
|
||||||
|
return &service.Config{
|
||||||
|
Name: svcName,
|
||||||
|
DisplayName: svcDisplayName,
|
||||||
|
Description: svcDescription,
|
||||||
|
WorkingDirectory: cfg.WorkPath,
|
||||||
|
Arguments: []string{
|
||||||
|
"--work-path", cfg.WorkPath,
|
||||||
|
"--config", cfg.ConfigFile,
|
||||||
|
},
|
||||||
|
Option: service.KeyValue{
|
||||||
|
// systemd:异常退出后自动拉起
|
||||||
|
"Restart": "always",
|
||||||
|
// Windows:崩溃后重启
|
||||||
|
"OnFailure": "restart",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServiceControl(s service.Service, action string) error {
|
||||||
|
if action == "status" {
|
||||||
|
st, err := s.Status()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch st {
|
||||||
|
case service.StatusRunning:
|
||||||
|
fmt.Println("服务状态: 运行中 (running)")
|
||||||
|
case service.StatusStopped:
|
||||||
|
fmt.Println("服务状态: 已停止 (stopped)")
|
||||||
|
default:
|
||||||
|
fmt.Println("服务状态: 未知 (unknown)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.Control(s, action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "install":
|
||||||
|
fmt.Println("服务已安装。可用以下命令启动:")
|
||||||
|
fmt.Printf(" %s --service start\n", os.Args[0])
|
||||||
|
fmt.Println("或使用系统工具:")
|
||||||
|
fmt.Println(" Linux: sudo systemctl start jiang13 && sudo systemctl enable jiang13")
|
||||||
|
fmt.Println(" Windows: Start-Service jiang13")
|
||||||
|
case "uninstall":
|
||||||
|
fmt.Println("服务已卸载")
|
||||||
|
case "start":
|
||||||
|
fmt.Println("服务已启动")
|
||||||
|
case "stop":
|
||||||
|
fmt.Println("服务已停止")
|
||||||
|
case "restart":
|
||||||
|
fmt.Println("服务已重启")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
410
cmd/monitor-geo-check/main.go
Normal file
410
cmd/monitor-geo-check/main.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
|
||||||
|
//
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
|
||||||
|
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("解析路径失败: %v", err)
|
||||||
|
}
|
||||||
|
dataDir := filepath.Dir(abs)
|
||||||
|
|
||||||
|
if *ipLookup == "" {
|
||||||
|
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||||
|
fatalf("数据库不存在: %s", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("数据库: %s\n", abs)
|
||||||
|
fmt.Printf("数据目录: %s\n\n", dataDir)
|
||||||
|
|
||||||
|
suite := service.NewGeoIPService(dataDir)
|
||||||
|
defer suite.Close()
|
||||||
|
printGeoFiles(dataDir, suite)
|
||||||
|
|
||||||
|
if ip := strings.TrimSpace(*ipLookup); ip != "" {
|
||||||
|
lookupIP(suite, ip)
|
||||||
|
fmt.Println()
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("打开数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("获取连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if !tableExists(db, "page_views") {
|
||||||
|
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
|
||||||
|
}
|
||||||
|
|
||||||
|
have := columnSet(db)
|
||||||
|
printColumnPresence(have)
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -30)
|
||||||
|
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
|
||||||
|
_ = db.Table("page_views").Count(&total).Error
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
|
||||||
|
|
||||||
|
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
|
||||||
|
fmt.Printf("总量: %d\n", total)
|
||||||
|
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
|
||||||
|
|
||||||
|
if have["country"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
|
||||||
|
fmt.Printf("有国家: %d\n", withCountry)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有国家: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["region"] || have["region_iso"] {
|
||||||
|
cond := "created_at >= ? AND is_bot = ?"
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Count(&withRegion).Error
|
||||||
|
fmt.Printf("有省字段: %d\n", withRegion)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有省字段: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["city"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
|
||||||
|
fmt.Printf("有城市: %d\n", withCity)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有城市: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["asn"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
|
||||||
|
fmt.Printf("有 ASN: %d\n", withASN)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有 ASN: (列缺失)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
|
||||||
|
if !(have["region"] || have["region_iso"]) {
|
||||||
|
fmt.Println("(省列尚未迁移,跳过)")
|
||||||
|
} else {
|
||||||
|
type regionRow struct {
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
selParts := []string{}
|
||||||
|
groupParts := []string{}
|
||||||
|
if have["country"] {
|
||||||
|
selParts = append(selParts, "country")
|
||||||
|
groupParts = append(groupParts, "country")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as country")
|
||||||
|
}
|
||||||
|
if have["region"] {
|
||||||
|
selParts = append(selParts, "region")
|
||||||
|
groupParts = append(groupParts, "region")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
selParts = append(selParts, "region_iso")
|
||||||
|
groupParts = append(groupParts, "region_iso")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region_iso")
|
||||||
|
}
|
||||||
|
selParts = append(selParts, "COUNT(*) as count")
|
||||||
|
var regions []regionRow
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(selParts, ", ")).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Group(strings.Join(groupParts, ", ")).
|
||||||
|
Order("count DESC").
|
||||||
|
Limit(20).
|
||||||
|
Scan(®ions).Error
|
||||||
|
if len(regions) == 0 {
|
||||||
|
fmt.Println("(空)")
|
||||||
|
} else {
|
||||||
|
for i, r := range regions {
|
||||||
|
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
|
||||||
|
printGeoReconcileSamples(db, have, since)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||||
|
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||||
|
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||||
|
if have[c] {
|
||||||
|
cols = append(cols, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type sample struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string `gorm:"column:as_org"`
|
||||||
|
Path string
|
||||||
|
IsBot bool
|
||||||
|
}
|
||||||
|
var samples []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(cols, ", ")).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(10).
|
||||||
|
Scan(&samples).Error
|
||||||
|
if len(samples) == 0 {
|
||||||
|
fmt.Println("(无近 30 日 pageview)")
|
||||||
|
} else {
|
||||||
|
for _, s := range samples {
|
||||||
|
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
|
||||||
|
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||||
|
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regionReady := have["region"] || have["region_iso"]
|
||||||
|
if !regionReady || withRegion == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
|
||||||
|
if !regionReady {
|
||||||
|
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||||
|
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||||
|
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
|
||||||
|
type sample struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string `gorm:"column:as_org"`
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
selCols := []string{"id", "created_at", "path"}
|
||||||
|
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||||
|
if have[c] {
|
||||||
|
selCols = append(selCols, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel := strings.Join(selCols, ", ")
|
||||||
|
|
||||||
|
printBlock := func(title string, n int64, rows []sample) {
|
||||||
|
fmt.Printf("%s: %d\n", title, n)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range rows {
|
||||||
|
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
|
||||||
|
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||||
|
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["city"] {
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||||
|
Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("英文城市名(可能未中文化)", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("英文城市名: (city 列缺失)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["region"] || have["region_iso"] {
|
||||||
|
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
|
||||||
|
emptyParts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
|
||||||
|
}
|
||||||
|
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where(where, since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("有国家但省为空", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有国家但省为空: (省列缺失)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["asn"] {
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||||
|
Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("asn=0(无运营商)", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("asn=0: (asn 列缺失)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||||
|
fmt.Println("=== Geo 数据文件 ===")
|
||||||
|
v4, v6, asn, country := suite.Paths()
|
||||||
|
for _, item := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
|
||||||
|
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
|
||||||
|
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
|
||||||
|
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
|
||||||
|
} {
|
||||||
|
st, err := os.Stat(item.path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%s: 不存在\n", item.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loaded := "未加载"
|
||||||
|
if item.ok {
|
||||||
|
loaded = "已加载"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupIP(suite *service.GeoIPService, ipStr string) {
|
||||||
|
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
|
||||||
|
info := suite.Lookup(ipStr)
|
||||||
|
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
|
||||||
|
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
|
||||||
|
if info.Region != "" || info.RegionISO != "" {
|
||||||
|
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
|
||||||
|
}
|
||||||
|
if info.City != "" {
|
||||||
|
fmt.Printf("城市: %s\n", info.City)
|
||||||
|
}
|
||||||
|
if info.ASN > 0 || info.ASOrg != "" {
|
||||||
|
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, name string) bool {
|
||||||
|
var n int
|
||||||
|
err := db.Raw(
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
|
||||||
|
).Scan(&n).Error
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnSet(db *gorm.DB) map[string]bool {
|
||||||
|
type colRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var cols []colRow
|
||||||
|
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, c := range cols {
|
||||||
|
have[c.Name] = true
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
func printColumnPresence(have map[string]bool) {
|
||||||
|
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
|
||||||
|
fmt.Println("=== page_views 列 ===")
|
||||||
|
missing := false
|
||||||
|
for _, n := range need {
|
||||||
|
if have[n] {
|
||||||
|
fmt.Printf("%s: 有\n", n)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: 缺失\n", n)
|
||||||
|
missing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
if missing {
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
243
config/config.go
243
config/config.go
@@ -5,67 +5,254 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 应用全局配置,通过命令行参数注入
|
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||||
|
const (
|
||||||
|
StorageTypeLocal = "local"
|
||||||
|
StorageTypeS3 = "s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// 工作目录(默认可执行文件所在目录)
|
||||||
|
WorkPath string
|
||||||
|
// 配置文件绝对路径
|
||||||
|
ConfigFile string
|
||||||
// 监听端口
|
// 监听端口
|
||||||
Port int
|
Port int
|
||||||
// 数据目录:SQLite 数据库、上传头像、日志文件均存放于此
|
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||||
DataDir string
|
DataDir string
|
||||||
// JWT 签名密钥
|
// JWT 签名密钥
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
// 日志文件路径(相对 DataDir)
|
// 日志文件路径
|
||||||
LogFile string
|
LogFile string
|
||||||
|
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||||
|
ServiceAction string
|
||||||
|
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||||
|
DevMode bool
|
||||||
|
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||||
|
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||||
|
CommunityHub bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse 解析命令行参数并初始化目录
|
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||||
|
//
|
||||||
|
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||||
func Parse() (*Config, error) {
|
func Parse() (*Config, error) {
|
||||||
port := flag.Int("port", 3000, "HTTP 监听端口")
|
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||||
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志)")
|
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||||
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化)")
|
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
|
||||||
|
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||||
|
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||||
|
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||||
|
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 确保数据目录存在
|
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||||
if err := os.MkdirAll(*dataDir, 0755); err != nil {
|
if action != "" && !validServiceAction(action) {
|
||||||
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||||
}
|
}
|
||||||
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
|
|
||||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
workPathInput := strings.TrimSpace(*workFlag)
|
||||||
return nil, fmt.Errorf("创建上传目录失败: %w", err)
|
if workPathInput == "" {
|
||||||
|
workPathInput = envOrDefault(envWorkPath)
|
||||||
|
}
|
||||||
|
workPath, err := resolveWorkPath(workPathInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configInput := strings.TrimSpace(*configFlag)
|
||||||
|
if configInput == "" {
|
||||||
|
configInput = envOrDefault(envConfig)
|
||||||
|
}
|
||||||
|
configFile, err := resolveConfigPath(workPath, configInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 p := envIntOrZero(envHTTPPort); p > 0 {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
if *portFlag > 0 {
|
||||||
|
port = *portFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
dataInput := fileCfg.DataRel
|
||||||
|
if v := envOrDefault(envData); v != "" {
|
||||||
|
dataInput = v
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*dataFlag) != "" {
|
||||||
|
dataInput = *dataFlag
|
||||||
|
}
|
||||||
|
absData, err := absPath(workPath, dataInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtSecret := fileCfg.JWTSecret
|
||||||
|
if v := envOrDefault(envJWTSecret); v != "" {
|
||||||
|
jwtSecret = v
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*jwtFlag) != "" {
|
||||||
|
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
communityHub := fileCfg.CommunityHub
|
||||||
|
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||||
|
communityHub = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: *port,
|
WorkPath: workPath,
|
||||||
DataDir: *dataDir,
|
ConfigFile: configFile,
|
||||||
JWTSecret: *jwtSecret,
|
Port: port,
|
||||||
LogFile: filepath.Join(*dataDir, "jiang13.log"),
|
DataDir: absData,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||||
|
ServiceAction: action,
|
||||||
|
DevMode: *devFlag,
|
||||||
|
CommunityHub: communityHub,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 JWT 密钥持久化
|
needDirs := action == "" || action == "install"
|
||||||
secretFile := filepath.Join(*dataDir, ".jwt_secret")
|
if needDirs {
|
||||||
if cfg.JWTSecret == "" {
|
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
if !configExists {
|
||||||
cfg.JWTSecret = string(data)
|
dataRel := resolveDataRelForINI(workPath, absData)
|
||||||
} else {
|
if err := writeAppINI(configFile, fileSettings{
|
||||||
cfg.JWTSecret = generateRandomSecret(32)
|
Port: port,
|
||||||
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
|
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
|
||||||
|
}
|
||||||
|
if err := cfg.resolveJWT(); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBPath 返回 SQLite 数据库文件路径
|
func resolveWorkPath(flagVal string) (string, error) {
|
||||||
|
if strings.TrimSpace(flagVal) != "" {
|
||||||
|
abs, err := filepath.Abs(flagVal)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析工作目录失败: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for _, sub := range []string{
|
||||||
|
filepath.Join(dataDir, "uploads", "avatars"),
|
||||||
|
filepath.Join(dataDir, "uploads", "posts"),
|
||||||
|
filepath.Join(dataDir, "uploads", "site"),
|
||||||
|
filepath.Join(dataDir, "logs", "access"),
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) resolveJWT() error {
|
||||||
|
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||||
|
if c.JWTSecret != "" {
|
||||||
|
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||||
|
c.JWTSecret = string(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.JWTSecret = generateRandomSecret(32)
|
||||||
|
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||||
|
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validServiceAction(action string) bool {
|
||||||
|
switch action {
|
||||||
|
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBPath 返回主库 SQLite 路径
|
||||||
func (c *Config) DBPath() string {
|
func (c *Config) DBPath() string {
|
||||||
return filepath.Join(c.DataDir, "jiang13.db")
|
return filepath.Join(c.DataDir, "jiang13.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadDir 返回头像上传目录
|
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||||
func (c *Config) UploadDir() string {
|
func (c *Config) MonitorDBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "monitor.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarUploadDir 返回头像上传目录
|
||||||
|
func (c *Config) AvatarUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
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 返回敏感词配置文件路径
|
// FilterWordsPath 返回敏感词配置文件路径
|
||||||
func (c *Config) FilterWordsPath() string {
|
func (c *Config) FilterWordsPath() string {
|
||||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||||
|
|||||||
51
config/env.go
Normal file
51
config/env.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
|
||||||
|
const (
|
||||||
|
envWorkPath = "JIANG13_WORK_PATH"
|
||||||
|
envConfig = "JIANG13_CONFIG"
|
||||||
|
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||||
|
envData = "JIANG13_DATA"
|
||||||
|
envJWTSecret = "JIANG13_JWT_SECRET"
|
||||||
|
envCommunityHub = "JIANG13_COMMUNITY_HUB"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
|
||||||
|
func envBoolOrNil(key string) *bool {
|
||||||
|
v := strings.ToLower(envOrDefault(key))
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
114
config/ini.go
Normal file
114
config/ini.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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
|
||||||
|
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("community"); err == nil {
|
||||||
|
if k := sec.Key("HUB"); k.String() != "" {
|
||||||
|
out.CommunityHub = k.MustBool(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
69
config/paths.go
Normal file
69
config/paths.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
|
||||||
|
func resolveAppPath() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
|
||||||
|
}
|
||||||
|
exe, err = filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
|
||||||
|
}
|
||||||
|
return exe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultWorkPath 类似 Gitea:默认可执行文件所在目录;
|
||||||
|
// go run 时二进制在临时目录,回退为当前工作目录。
|
||||||
|
func defaultWorkPath() (string, error) {
|
||||||
|
exe, err := resolveAppPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
if isEphemeralExeDir(dir) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取当前目录失败: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(wd), nil
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEphemeralExeDir(dir string) bool {
|
||||||
|
lower := strings.ToLower(filepath.Clean(dir))
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
markers := []string{
|
||||||
|
sep + "go-build",
|
||||||
|
sep + "go-run",
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
markers = append(markers, "\\go-build", "\\go-run")
|
||||||
|
}
|
||||||
|
for _, m := range markers {
|
||||||
|
if strings.Contains(lower, strings.ToLower(m)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func absPath(base, p string) (string, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("路径为空")
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return filepath.Clean(p), nil
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Join(base, p))
|
||||||
|
}
|
||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 姜十三论坛 — Docker Compose 双服务部署
|
||||||
|
# 架构:web(Node :3000 对外:Vike SSR + 静态资源 + API 反代) → api(Go :3001 内部:纯 API + /data 数据卷)
|
||||||
|
# 启动:docker compose up -d --build
|
||||||
|
# 或:make compose-up / build.bat -Target compose-up
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: hangzhang714128/jiang13-forum:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: dev
|
||||||
|
command: ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||||
|
volumes:
|
||||||
|
- jiang13-data:/data
|
||||||
|
# 可选:挂载自定义 app.ini(只读)
|
||||||
|
# - ./app.ini:/app/app.ini:ro
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
|
||||||
|
# JIANG13_JWT_SECRET: your-secret-here
|
||||||
|
expose:
|
||||||
|
- "3001"
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 15s
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: hangzhang714128/jiang13-forum:latest
|
||||||
|
entrypoint: ["node", "server/index.mjs"]
|
||||||
|
working_dir: /app/frontend
|
||||||
|
user: "1000:1000"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
PORT: "3000"
|
||||||
|
GO_API_URL: http://api:3001
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jiang13-data:
|
||||||
16
docker-entrypoint.sh
Normal file
16
docker-entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# api 容器入口:默认运行 Go API(web 容器在 compose 中以 node 用户直接覆盖 entrypoint)。
|
||||||
|
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||||
|
# 而应用以 uid 1000(node 镜像内置用户)运行,需在启动时修正 /data 权限后降权。
|
||||||
|
|
||||||
|
DATA_DIR="/data"
|
||||||
|
|
||||||
|
if [ "$(id -u)" = '0' ]; then
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
chown -R 1000:1000 "$DATA_DIR"
|
||||||
|
exec gosu 1000:1000 /app/jiang13 "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /app/jiang13 "$@"
|
||||||
105
docs/docker-data.md
Normal file
105
docs/docker-data.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Docker 数据持久化说明
|
||||||
|
|
||||||
|
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
|
||||||
|
|
||||||
|
## 挂载对应关系
|
||||||
|
|
||||||
|
| 宿主机 | 容器内 |
|
||||||
|
|--------|--------|
|
||||||
|
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /你的路径/jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 `docker-compose.yml` 中:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- jiang13-data:/data
|
||||||
|
# 或绑定宿主机目录:
|
||||||
|
# - ./data:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## `/data` 目录结构
|
||||||
|
|
||||||
|
数据库与附件都在同一个 `/data` 挂载内:
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/
|
||||||
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
|
├── uploads/ # 附件根目录
|
||||||
|
│ ├── avatars/ # 用户头像
|
||||||
|
│ └── posts/ # 帖子图片等
|
||||||
|
├── logs/
|
||||||
|
│ └── access/ # 网站监控请求日志(按日 jsonl)
|
||||||
|
│ └── YYYY-MM-DD.jsonl
|
||||||
|
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
|
||||||
|
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
|
||||||
|
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
|
||||||
|
├── GeoLite2-Country.mmdb # 可选:国家兜底
|
||||||
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── filter_words.txt # 敏感词配置
|
||||||
|
└── jiang13.log # 运行日志(若启用)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 主数据库 | `/data/jiang13.db` |
|
||||||
|
| 监控浏览量 | `/data/monitor.db` |
|
||||||
|
| 附件 | `/data/uploads/` |
|
||||||
|
| 请求日志(监控) | `/data/logs/access/` |
|
||||||
|
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
|
||||||
|
|
||||||
|
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
|
||||||
|
|
||||||
|
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
|
||||||
|
|
||||||
|
## 设计说明:为何只挂一个 `/data`
|
||||||
|
|
||||||
|
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
|
||||||
|
|
||||||
|
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
|
||||||
|
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
|
||||||
|
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
|
||||||
|
|
||||||
|
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
|
||||||
|
|
||||||
|
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
|
||||||
|
|
||||||
|
## 后续计划(可选增强,非必须)
|
||||||
|
|
||||||
|
按优先级,仅在有明确需求时做:
|
||||||
|
|
||||||
|
1. **文档与面板提示(优先)**
|
||||||
|
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
|
||||||
|
- 本页保持为权威说明,避免用户误以为漏了一个卷。
|
||||||
|
|
||||||
|
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
|
||||||
|
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# 推荐:父目录一次挂载
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
- /hdd/jiang13-uploads:/data/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
|
||||||
|
|
||||||
|
3. **产品级:可配置上传根目录(中期)**
|
||||||
|
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
|
||||||
|
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
|
||||||
|
|
||||||
|
4. **不做的事**
|
||||||
|
- 不为「看起来像 WordPress」而强制双卷。
|
||||||
|
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。
|
||||||
158
docs/introduction.md
Normal file
158
docs/introduction.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 姜十三论坛:为小圈子而生的轻量论坛
|
||||||
|
|
||||||
|
> 轻量 · 好看 · 单文件部署
|
||||||
|
> 面向内部交流,不堆功能,把「能聊、好看、好装」做到位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 它是什么
|
||||||
|
|
||||||
|
**姜十三论坛(Jiang13 Forum)** 是一款面向小圈子、团队与兴趣社群的现代化论坛软件。
|
||||||
|
|
||||||
|
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
|
||||||
|
|
||||||
|
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA(单页应用)通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么做它
|
||||||
|
|
||||||
|
市面上的论坛方案往往落在两端:
|
||||||
|
|
||||||
|
- **重型产品**(Discourse、phpBB 等):功能完整,但部署与运维成本高,对小团队过重。
|
||||||
|
- **即时通讯 / 文档工具**:适合聊天与协作,却缺少「发帖—回复—沉淀」的社区节奏。
|
||||||
|
|
||||||
|
小圈子真正需要的,常常是中间态:
|
||||||
|
|
||||||
|
- 有板块、帖子、楼层回复,讨论可追溯;
|
||||||
|
- 界面清爽、信息密度够高,桌面与手机都好用;
|
||||||
|
- 部署简单到「一个文件 + 一份配置」;
|
||||||
|
- 数据在自己手里,备份一眼能懂。
|
||||||
|
|
||||||
|
姜十三论坛正是为这个缺口而生。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 谁适合用
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 团队 / 工作室内部论坛 | 需求讨论、进度同步、知识沉淀 |
|
||||||
|
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||||
|
| 开源 / 私有项目配套社区 | 与 Gitea 等工具通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||||
|
| 想自己托管的个人站长 | 单机即可,无需云数据库与一堆微服务 |
|
||||||
|
|
||||||
|
如果你需要百万用户级的公网社区、复杂插件生态或企业级工单流,它可能不是最优选;如果你要的是 **小而美、自己能装、自己能管**,它会对胃口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用起来是什么感觉
|
||||||
|
|
||||||
|
### 浏览:高密度、不臃肿
|
||||||
|
|
||||||
|
前台采用熟悉的 **三栏布局**:
|
||||||
|
|
||||||
|
- **左栏**:板块导航,可折叠;
|
||||||
|
- **中间**:帖子 Feed(信息流),支持虚拟滚动,长列表依然流畅;
|
||||||
|
- **右栏**:热门帖与最新评论,社区动态一目了然。
|
||||||
|
|
||||||
|
Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗色主题一键切换,并会记住你的偏好。平板与手机上侧栏会自动收起,触控浏览同样顺手。
|
||||||
|
|
||||||
|
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
|
||||||
|
|
||||||
|
### 发帖:富文本,够用就好
|
||||||
|
|
||||||
|
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff(差异)对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
||||||
|
|
||||||
|
### 讨论:楼层回复,聊得清楚
|
||||||
|
|
||||||
|
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
||||||
|
|
||||||
|
### 管理:后台也是 SPA
|
||||||
|
|
||||||
|
管理后台统一在 `/admin`,与前台同一套 React 体验:
|
||||||
|
|
||||||
|
- 仪表盘、板块与帖子管理
|
||||||
|
- 用户禁言、删帖删评
|
||||||
|
- 论坛参数、限流、敏感词
|
||||||
|
- SQLite **一键备份**
|
||||||
|
|
||||||
|
权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署:一个文件,真正开箱
|
||||||
|
|
||||||
|
这大概是姜十三论坛最「硬核」的卖点之一。
|
||||||
|
|
||||||
|
```text
|
||||||
|
编译 → 得到一个 jiang13(或 jiang13.exe)
|
||||||
|
放到目录里运行 → 自动生成 app.ini
|
||||||
|
打开浏览器注册 → 第一个账号就是管理员
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
|
||||||
|
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
|
||||||
|
- **app.ini 配置**:风格类似 Gitea,端口与数据目录一眼能改;业务项在管理后台配置;
|
||||||
|
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
|
||||||
|
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
|
||||||
|
|
||||||
|
典型启动后访问 `http://localhost:3000`,注册即可开始。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术素描(给好奇的人)
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 后端 | Go · Gin · GORM · SQLite |
|
||||||
|
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||||
|
| 构建 | Vite 构建 SPA,再由 `go:embed` 打进二进制 |
|
||||||
|
| 认证 | bcrypt + JWT Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
||||||
|
|
||||||
|
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR(热模块替换)可秒级预览前端改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 现在走到哪一步
|
||||||
|
|
||||||
|
项目仍在积极开发中,核心体验已经可用:
|
||||||
|
|
||||||
|
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序
|
||||||
|
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史
|
||||||
|
- ✅ React 管理后台与论坛参数配置
|
||||||
|
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
|
||||||
|
- ✅ 单二进制部署与系统服务
|
||||||
|
|
||||||
|
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话总结
|
||||||
|
|
||||||
|
**姜十三论坛** = 小圈子论坛该有的功能 × 清新好用的界面 × 真正能单文件带走的部署方式。
|
||||||
|
|
||||||
|
如果你正在给团队或同好找一个「自己的小论坛」,不妨编译跑一下:注册第一个账号,从发第一帖开始。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速上手
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
.\build.bat
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
make build
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./dist/jiang13 # 或 Windows: .\dist\jiang13.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 `http://localhost:3000/register`,第一个注册用户即为管理员。
|
||||||
|
|
||||||
|
更多细节(配置项、系统服务、前端开发)见项目 [README](../README.md)。欢迎通过 Issue / PR 参与共建。
|
||||||
|
|
||||||
|
**许可证:** MIT
|
||||||
92
docs/issue-templates.md
Normal file
92
docs/issue-templates.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Issue 预填模板
|
||||||
|
|
||||||
|
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #1 · 评论回复换行不显示
|
||||||
|
|
||||||
|
**标题:** `[Bug] 评论回复换行不显示`
|
||||||
|
|
||||||
|
**标签:** `bug` `ui/ux`
|
||||||
|
|
||||||
|
**正文:**
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
|
||||||
|
|
||||||
|
### 复现步骤
|
||||||
|
|
||||||
|
1. 打开任意帖子详情页(如 `/post/2`)
|
||||||
|
2. 在底部评论框输入:
|
||||||
|
```
|
||||||
|
第一行
|
||||||
|
第二行
|
||||||
|
第三行
|
||||||
|
```
|
||||||
|
3. 点击发送
|
||||||
|
4. 查看刚发布的评论
|
||||||
|
|
||||||
|
### 期望行为
|
||||||
|
|
||||||
|
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
|
||||||
|
|
||||||
|
### 实际行为
|
||||||
|
|
||||||
|
多行内容被渲染成单行连续文字。
|
||||||
|
|
||||||
|
### 相关代码
|
||||||
|
|
||||||
|
- `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` 或其他处理误删换行(待排查)
|
||||||
|
|
||||||
|
### 环境
|
||||||
|
|
||||||
|
- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版)
|
||||||
|
- 浏览器:Chrome / Edge 最新版
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #2 · 示例:管理能力扩展(模板文案)
|
||||||
|
|
||||||
|
**标题:** `[Feature] 管理后台增加某某能力`
|
||||||
|
|
||||||
|
**标签:** `enhancement` `ui/ux` `good first issue`
|
||||||
|
|
||||||
|
**正文:**
|
||||||
|
|
||||||
|
### 要解决的问题
|
||||||
|
|
||||||
|
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
| 能力 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 数据模型与业务逻辑 | ✅ / ❌ |
|
||||||
|
| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ |
|
||||||
|
| React 管理后台入口 | ✅ / ❌ |
|
||||||
|
| React 前台操作入口(如适用) | ✅ / ❌ |
|
||||||
|
|
||||||
|
### 期望方案
|
||||||
|
|
||||||
|
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见)
|
||||||
|
2. 调用已有或新增的 `/api/admin/*` JSON API
|
||||||
|
3. 成功后刷新列表/详情,无需离开当前页面
|
||||||
|
|
||||||
|
### 相关代码
|
||||||
|
|
||||||
|
- 后端:`service/`、`handler/api.go`、`router/router.go`
|
||||||
|
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts`
|
||||||
|
|
||||||
|
### 备注
|
||||||
|
|
||||||
|
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。
|
||||||
79
docs/monitor.md
Normal file
79
docs/monitor.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 网站监控设计说明
|
||||||
|
|
||||||
|
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
|
||||||
|
|
||||||
|
## 双通道数据
|
||||||
|
|
||||||
|
| 数据 | 存储 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
|
||||||
|
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
|
||||||
|
|
||||||
|
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
|
||||||
|
|
||||||
|
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
|
||||||
|
|
||||||
|
### 文件行格式(JSONL)
|
||||||
|
|
||||||
|
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
|
||||||
|
|
||||||
|
### 保留天数
|
||||||
|
|
||||||
|
- `monitor_retention_days`:仅 **page_views**(默认 30)
|
||||||
|
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
|
||||||
|
|
||||||
|
## 地理数据文件
|
||||||
|
|
||||||
|
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
|
||||||
|
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
|
||||||
|
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
|
||||||
|
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
|
||||||
|
|
||||||
|
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
|
||||||
|
|
||||||
|
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
|
||||||
|
|
||||||
|
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
|
||||||
|
|
||||||
|
## 写入路径(性能)
|
||||||
|
|
||||||
|
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||||
|
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
|
||||||
|
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
|
||||||
|
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
|
||||||
|
|
||||||
|
**热路径零 Geo、零写库、零写文件。**
|
||||||
|
|
||||||
|
## 请求日志口径(排除规则)
|
||||||
|
|
||||||
|
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
|
||||||
|
|
||||||
|
- `/admin`、`/api/admin/`
|
||||||
|
- `/api/me`、`/api/site-branding`
|
||||||
|
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
|
||||||
|
|
||||||
|
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
|
||||||
|
|
||||||
|
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
|
||||||
|
|
||||||
|
## 实时与聚合
|
||||||
|
|
||||||
|
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
|
||||||
|
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
|
||||||
|
- 地理排行:仅 `page_views`。
|
||||||
|
|
||||||
|
## 诊断
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
```
|
||||||
|
|
||||||
|
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||||
|
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB
|
||||||
BIN
docs/screenshots/home-dark.png
Normal file
BIN
docs/screenshots/home-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/screenshots/home-light.png
Normal file
BIN
docs/screenshots/home-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/screenshots/mobile-home.png
Normal file
BIN
docs/screenshots/mobile-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/screenshots/post-detail.png
Normal file
BIN
docs/screenshots/post-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/screenshots/post-rich.png
Normal file
BIN
docs/screenshots/post-rich.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
46
docs/site-posts/README.md
Normal file
46
docs/site-posts/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 站务公告 · 发帖素材
|
||||||
|
|
||||||
|
## 板块设置
|
||||||
|
|
||||||
|
| 项 | 建议值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 名称 | 站务公告 |
|
||||||
|
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
|
||||||
|
| 图标 | `megaphone` |
|
||||||
|
| 色标 | 建议第 0 或 1 槽 |
|
||||||
|
| 排序 | `0` |
|
||||||
|
|
||||||
|
## 主文(必发)
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
|
||||||
|
|
||||||
|
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||||
|
|
||||||
|
## 自定义单页(推荐)
|
||||||
|
|
||||||
|
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
|
||||||
|
|
||||||
|
| 文件 | 标题 | slug | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
|
||||||
|
|
||||||
|
访问路径:`/page/changelog`。
|
||||||
|
|
||||||
|
## 部署指南(置顶帖)
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
|
||||||
|
|
||||||
|
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
|
||||||
|
|
||||||
|
## 可选短文
|
||||||
|
|
||||||
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
|
|
||||||
|
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
|
||||||
|
- [feedback.md](./feedback.md) — 反馈与建议
|
||||||
|
|
||||||
|
复制正文时:从各文件第一个 `---` **之后**开始粘贴。
|
||||||
185
docs/site-posts/about-jiang13.md
Normal file
185
docs/site-posts/about-jiang13.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# 关于本站 · 姜十三论坛
|
||||||
|
|
||||||
|
> 建议标题:关于本站 · 姜十三论坛
|
||||||
|
> 建议标签:`站务` `介绍`
|
||||||
|
> 建议操作:置顶(可选精华)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你好,欢迎加入本站。
|
||||||
|
|
||||||
|
这里运行的是开源论坛 **姜十三论坛(Jiang13 Forum)**——面向小圈子、团队与同好社群,目标很明确:
|
||||||
|
|
||||||
|
**能聊 · 好看 · 好装**
|
||||||
|
|
||||||
|
下文介绍它是什么、怎么上手、有哪些能力,以及为何采用单文件部署。读完即可开始发帖;细节可随时回来查阅。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、它是什么
|
||||||
|
|
||||||
|
姜十三论坛不是大而全的社区平台,而是一款轻量、现代化的论坛软件。
|
||||||
|
|
||||||
|
它做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
|
||||||
|
|
||||||
|
- 有板块、帖子、楼层回复,讨论能沉淀、能追溯
|
||||||
|
- 界面清爽,信息密度适中,桌面与手机都好用
|
||||||
|
- 单个程序即可部署,备份路径清晰
|
||||||
|
|
||||||
|
技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。拷到服务器、启动、注册——就可以用。
|
||||||
|
|
||||||
|
### 谁适合用
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||||
|
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||||
|
| 项目配套社区 | 可与 Gitea 等做 SSO 联动 |
|
||||||
|
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
|
||||||
|
|
||||||
|
若你需要百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。
|
||||||
|
若你要的是 **小而美、自己能装、自己能管**,就对了。
|
||||||
|
|
||||||
|
### 本板块说明
|
||||||
|
|
||||||
|
「站务公告」只发站务与介绍类内容。日常聊天、提问、分享,请到其他讨论板块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、三分钟上手
|
||||||
|
|
||||||
|
### 注册与登录
|
||||||
|
|
||||||
|
1. 打开注册页,填写用户名、邮箱与密码。
|
||||||
|
2. 若已开启邮件服务,可能需要邮箱验证码。
|
||||||
|
3. **第一个注册用户自动成为管理员**,无需安装向导。
|
||||||
|
|
||||||
|
登录后,右上角会出现头像与私信入口。
|
||||||
|
|
||||||
|
### 认识首页
|
||||||
|
|
||||||
|
| 区域 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 左侧 | 板块导航、常用入口 |
|
||||||
|
| 中间 | 帖子列表(Feed) |
|
||||||
|
| 右侧 | 热门帖、最新评论等 |
|
||||||
|
|
||||||
|
列表可按 **最新发帖 / 最新回复 / 热门** 切换。
|
||||||
|
支持浅色 / 暗色主题,偏好会记住。手机上侧栏会收起,不影响浏览与发帖。
|
||||||
|
|
||||||
|
### 发帖与回复
|
||||||
|
|
||||||
|
点「发帖」→ 选板块 → 写标题、标签(可选)与正文。
|
||||||
|
正文支持富文本与图片上传。评论是 **楼层式** 的,可回复、引用、@。
|
||||||
|
|
||||||
|
帖子可点赞、收藏;公开主页可展示你的资料与动态。
|
||||||
|
|
||||||
|
### 个人入口
|
||||||
|
|
||||||
|
头像菜单常见项:个人主页、账号设置、站内私信、我的收藏。
|
||||||
|
|
||||||
|
### 管理后台(仅管理员)
|
||||||
|
|
||||||
|
访问 `/admin`:板块、帖子、评论、举报、用户、媒体、系统设置,以及备份等。
|
||||||
|
|
||||||
|
| 改什么 | 去哪里 |
|
||||||
|
| --- | --- |
|
||||||
|
| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) |
|
||||||
|
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
|
||||||
|
|
||||||
|
逛完板块,发第一帖或回一楼——聊起来就对了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能速览
|
||||||
|
|
||||||
|
功能刻意收在「小圈子真正用得到」的范围内。
|
||||||
|
|
||||||
|
### 浏览与发帖
|
||||||
|
|
||||||
|
- 三栏布局,桌面 / 移动自适应
|
||||||
|
- Feed 排序与虚拟滚动
|
||||||
|
- 浅色 / 暗色主题;板块图标与主题色可配
|
||||||
|
- TipTap 富文本、标签、正文图片
|
||||||
|
- 楼层评论:回复、引用、@
|
||||||
|
- 点赞、收藏;置顶 / 精华
|
||||||
|
- 编辑修订历史(可对比差异)
|
||||||
|
- 可配置普通用户的编辑时限
|
||||||
|
|
||||||
|
### 社交与个人
|
||||||
|
|
||||||
|
- 公开用户主页
|
||||||
|
- 会话式站内私信
|
||||||
|
- 昵称、签名、头像等资料
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
|
||||||
|
与前台同一套 React 体验,统一在 `/admin`:
|
||||||
|
|
||||||
|
| 模块 | 能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 仪表盘 | 站点概览 |
|
||||||
|
| 板块 | 创建、编辑、排序、外观 |
|
||||||
|
| 帖子 | 审核、置顶、精华、锁定、回收站 |
|
||||||
|
| 评论 / 举报 | 审核与处理 |
|
||||||
|
| 用户 | 禁言等 |
|
||||||
|
| 媒体 | 查看与清理上传 |
|
||||||
|
| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 |
|
||||||
|
| 备份 | SQLite 一键备份 |
|
||||||
|
|
||||||
|
### 可选能力
|
||||||
|
|
||||||
|
按需在后台开启,不是装完就必须用:
|
||||||
|
|
||||||
|
- 邮件验证码注册
|
||||||
|
- OIDC Provider(可作 Gitea 等登录源)
|
||||||
|
- Gitea 公开仓库同步到「开源码桶」
|
||||||
|
- 本地磁盘或 S3 兼容对象存储
|
||||||
|
|
||||||
|
### 权限
|
||||||
|
|
||||||
|
只有两种角色:**普通用户** / **管理员**。
|
||||||
|
第一个注册用户即管理员。小圈子够用,不堆角色矩阵。
|
||||||
|
|
||||||
|
项目仍在积极开发。完整规划见仓库 `ROADMAP.md`,欢迎反馈与共建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、为什么是单文件部署
|
||||||
|
|
||||||
|
最硬核的一点,是部署路径极短——不是「理论上能打包」,而是日常用法就是:
|
||||||
|
|
||||||
|
1. 编译得到一个二进制
|
||||||
|
2. 放到目录里运行
|
||||||
|
3. 自动生成 `app.ini`
|
||||||
|
4. 浏览器注册,第一个账号即管理员
|
||||||
|
|
||||||
|
### 它补哪块空缺
|
||||||
|
|
||||||
|
论坛方案常落在两端:很重(功能全、运维贵),或很碎(聊天 / 文档方便,却缺少发帖沉淀)。
|
||||||
|
小圈子往往需要中间态:有论坛结构、界面顺手、一个人能装、备份好懂。单文件部署,就是为后两点服务的。
|
||||||
|
|
||||||
|
### 具体意味着什么
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
|
||||||
|
| 内置 SQLite | 零外部数据库,数据在本地目录 |
|
||||||
|
| 精简 `app.ini` | 主要管端口、数据目录、JWT |
|
||||||
|
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
|
||||||
|
| 系统服务 | 内置 Linux systemd / Windows Service |
|
||||||
|
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
|
||||||
|
|
||||||
|
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
|
||||||
|
|
||||||
|
进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 写在最后
|
||||||
|
|
||||||
|
**姜十三论坛** = 小圈子该有的论坛能力 × 清新界面 × 真正能单文件带走的部署。
|
||||||
|
|
||||||
|
源码与说明见项目仓库。许可证:MIT。
|
||||||
|
|
||||||
|
有问题可联系管理员。祝你在这里聊得愉快。
|
||||||
193
docs/site-posts/changelog.md
Normal file
193
docs/site-posts/changelog.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
> 建议标题:更新日志
|
||||||
|
> 建议 slug:`changelog`
|
||||||
|
> 建议路径:`/page/changelog`
|
||||||
|
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
|
||||||
|
|
||||||
|
**当前版本:1.1.8**(2026-09-02)
|
||||||
|
|
||||||
|
## 去哪看新版本
|
||||||
|
|
||||||
|
| 渠道 | 看什么 | 地址 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
|
||||||
|
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
|
||||||
|
| Windows / Linux / macOS | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
|
||||||
|
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
|
||||||
|
|
||||||
|
**1.1.8 直接下载:**
|
||||||
|
|
||||||
|
- Windows x64:[jiang13_v1.1.8_windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_windows-amd64.exe)
|
||||||
|
- Linux x64:[jiang13_v1.1.8_linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64)
|
||||||
|
- macOS arm64:[jiang13_v1.1.8_darwin-arm64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_darwin-arm64)
|
||||||
|
|
||||||
|
拉取指定版本镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:1.1.8
|
||||||
|
# 或始终跟随最新
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
|
||||||
|
|
||||||
|
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
|
||||||
|
|
||||||
|
## 升级注意
|
||||||
|
|
||||||
|
- 一般只需换镜像或换 exe,数据目录向后兼容。
|
||||||
|
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
|
||||||
|
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.8 · 2026-09-02
|
||||||
|
|
||||||
|
表情选择与评论富文本展示完善,私信表情不再发成图片附件。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 表情选择器在评论富文本中可直接插入,与文章编辑器一致
|
||||||
|
- 私信页支持表情选择器,点击表情直接插入文本框
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 私信中插入的表情改为文本内容发送,不再作为图片附件
|
||||||
|
- 消息未读数在多端间同步更及时
|
||||||
|
- 帖子详情快照逻辑修复,避免打开旧缓存页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.7 · 2026-09-02
|
||||||
|
|
||||||
|
私信与开源展柜体验升级,待审通知的审核态更及时。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
|
||||||
|
- 主页「发私信」直达消息页对应会话
|
||||||
|
- 开源展柜改为目录式 UI,空列表不再粘滞
|
||||||
|
- 消息页体验重构;待审通知打开后实时回填审核态
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.6 · 2026-09-01
|
||||||
|
|
||||||
|
编辑器插入体验与首页默认 Feed 排序更合理。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
|
||||||
|
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
|
||||||
|
- 链接是否新标签打开交由全站设置,对话框不再单独开关
|
||||||
|
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
|
||||||
|
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.5 · 2026-09-01
|
||||||
|
|
||||||
|
首页首屏与交互更稳,Feed 排序语义更清楚。
|
||||||
|
本版起同时提供 **Docker 镜像** 与 **Windows / Linux 预编译单文件**(Gitea Release `v1.1.5`)。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 首页改为 Go SSR 与 React hydrate(注水)同构,打开首页不再先闪一层空壳再跳内容
|
||||||
|
- Feed 排序改为:**新评论 / 新帖子 / 推荐帖**;推荐帖只出精华(featured)
|
||||||
|
- 点击排序会强制刷新列表;软刷新时同步站点限额与品牌文案
|
||||||
|
- 返回列表用缓存恢复滚动位置,不再重复请求
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 发版后软刷新会检测入口壳;chunk(代码分片)404 时自动硬刷新,避免卡在旧页面
|
||||||
|
- 软刷新齐套前保留旧画面,避免一点击就把内容卸光
|
||||||
|
- 补齐 `/favicon.ico` 与页面 head 图标;拆除爬虫专用 HTML
|
||||||
|
- 桌面端隐藏多余的导航汉堡按钮
|
||||||
|
- 帖行评论数、列表「共 N 条」右对齐;排序栏数字紧跟标签
|
||||||
|
- 手机端 SSR 顶栏 / 页脚与 React 一致,减少闪动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.4 · 2026-08-31
|
||||||
|
|
||||||
|
网站监控的地理信息与口径更准确。
|
||||||
|
|
||||||
|
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
|
||||||
|
- 对齐监控概览「双通道」口径文案与诊断抽样说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.3 · 2026-08-31
|
||||||
|
|
||||||
|
管理后台可看站点访问情况。
|
||||||
|
|
||||||
|
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.2 · 2026-08-31
|
||||||
|
|
||||||
|
可选接入官方社区展柜。
|
||||||
|
|
||||||
|
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.1 · 2026-08-31
|
||||||
|
|
||||||
|
- 评论管理操作收进「更多」菜单,管理员界面更干净
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.1.0 · 2026-08-30
|
||||||
|
|
||||||
|
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
- 友链申请、独立友链页;页脚左右区域可分别开关
|
||||||
|
- 自定义单页(后台「单页管理」,如本页)
|
||||||
|
- 投票帖、悬赏帖、抽奖帖
|
||||||
|
- 侧栏签到;右侧栏「最新注册」
|
||||||
|
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
|
||||||
|
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
|
||||||
|
|
||||||
|
**优化**
|
||||||
|
|
||||||
|
- 首页帖子列表密度与标题样式
|
||||||
|
- 开源码桶展示加强,去掉 Feed 顶栏统计
|
||||||
|
- 单页编辑、列表留白与友链申请体验
|
||||||
|
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 手机端搜索入口与底部 Sheet(抽屉面板)
|
||||||
|
- 占位头像对比度;无头像用户不再误用游客灰底
|
||||||
|
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
|
||||||
|
- 单页正文排版与帖子详情对齐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v1.0.0 · 2026-08-23
|
||||||
|
|
||||||
|
首次发布 Docker 镜像 `hangzhang714128/jiang13-forum:1.0.0`。
|
||||||
|
|
||||||
|
当时已包含论坛核心能力:板块与发帖、楼层评论、点赞收藏、私信、TipTap 富文本、贴纸、修订历史、管理后台、OIDC(开放身份连接)/ SSO(单点登录)、邮件验证码、Gitea 仓库同步、SQLite 单二进制部署,以及 Docker 一键运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
之后发版时:Docker 推 `x.y.z` + `latest`,Gitea 打 tag `vx.y.z` 并挂上对应 exe / linux 文件,再把新版本写到本页最上方。
|
||||||
30
docs/site-posts/community-guidelines.md
Normal file
30
docs/site-posts/community-guidelines.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 本站发帖约定
|
||||||
|
|
||||||
|
> 建议标题:本站发帖约定
|
||||||
|
> 建议标签:`站务` `公约`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站是小圈子论坛,约定尽量少、好遵守。
|
||||||
|
|
||||||
|
## 请这样做
|
||||||
|
|
||||||
|
- 选对板块再发;不确定就发到综合讨论类板块
|
||||||
|
- 标题写清楚在说什么,方便后人搜索
|
||||||
|
- 讨论对事不对人;引用、回复尽量指向具体楼层
|
||||||
|
- 分享链接时简单说明来源与用途
|
||||||
|
|
||||||
|
## 请避免
|
||||||
|
|
||||||
|
- 在「站务公告」灌水、闲聊(请到其他板块)
|
||||||
|
- 无意义刷屏、重复灌帖
|
||||||
|
- 人身攻击、骚扰、泄露他人隐私
|
||||||
|
- 明显的广告、引流(除非板块明确允许)
|
||||||
|
|
||||||
|
## 管理说明
|
||||||
|
|
||||||
|
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
|
||||||
|
有争议可私信管理员说明情况。
|
||||||
|
|
||||||
|
一句话:**把讨论留给值得留下的内容。**
|
||||||
244
docs/site-posts/deployment-guide.md
Normal file
244
docs/site-posts/deployment-guide.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 从零开始部署姜十三论坛
|
||||||
|
|
||||||
|
> 建议标题:从零开始部署姜十三论坛
|
||||||
|
> 建议 slug:`deploy`
|
||||||
|
> 建议操作:发布后设为「置顶」
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么选姜十三
|
||||||
|
|
||||||
|
单二进制 + 内置 SQLite,**不需要 MySQL、PHP、Redis**。面板(宝塔 / 1Panel)只是帮你更方便地管理,不是必须的。
|
||||||
|
|
||||||
|
## 服务器选型
|
||||||
|
|
||||||
|
| 部署方式 | CPU | 内存 | 磁盘 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 纯二进制(无面板) | 1 核 | 512MB | 10GB SSD |
|
||||||
|
| 二进制 + 宝塔 | 1 核 | 1GB | 20GB SSD |
|
||||||
|
| Docker + 1Panel | 2 核 | 2GB | 40GB SSD |
|
||||||
|
|
||||||
|
> 推荐 Debian 12 / Ubuntu 22.04。建议直接上 2 核 2G。
|
||||||
|
|
||||||
|
## 部署方式对比
|
||||||
|
|
||||||
|
| | 1Panel + Docker | 宝塔 + 二进制 | 纯命令行 Docker |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 适合 | 新手 / 还要跑其他容器 | 习惯宝塔 / 只跑这一个 | 熟悉 Linux / 资源紧张 |
|
||||||
|
| 资源占用 | 较高(自带 Docker) | 低 | 最低 |
|
||||||
|
| HTTPS | 面板一键 | 面板一键 | Caddy / certbot |
|
||||||
|
| 备份 | 面板计划任务 | 面板计划任务 | `tar` 打包 |
|
||||||
|
| 升级 | 面板点一下 | 替换二进制 | `docker pull` + 重启 |
|
||||||
|
|
||||||
|
## 前置准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
apt install -y curl wget ca-certificates
|
||||||
|
timedatectl set-timezone Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式一:1Panel + Docker
|
||||||
|
|
||||||
|
### 1. 安装 1Panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh \
|
||||||
|
-o quick_start.sh && bash quick_start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- 安装目录:默认 `/opt`
|
||||||
|
- 是否装 Docker:**y**(必须)
|
||||||
|
- 面板端口 / 安全入口 / 账号密码自定义,记好
|
||||||
|
|
||||||
|
安全组放行设置的端口,然后浏览器打开面板。
|
||||||
|
|
||||||
|
### 2. 启动姜十三
|
||||||
|
|
||||||
|
1Panel 面板 → 容器 → 创建:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 镜像 | `hangzhang714128/jiang13-forum:latest` |
|
||||||
|
| 端口 | `3000:3000` |
|
||||||
|
| 数据卷 | 主机目录 → 容器内 **`/data`** |
|
||||||
|
| 重启策略 | `unless-stopped` |
|
||||||
|
|
||||||
|
浏览器访问 `http://IP:3000` → **首个注册用户自动成为管理员**。
|
||||||
|
|
||||||
|
> 若容器报 `permission denied`:`chown -R 1000:1000 /数据目录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式二:宝塔 + 二进制
|
||||||
|
|
||||||
|
### 1. 安装宝塔
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 正式版 13.0.0(含 AI,需绑定账号)
|
||||||
|
if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh KZOqff1mn
|
||||||
|
|
||||||
|
# 稳定版 12.0.0(纯净、免绑定)
|
||||||
|
url=https://download.bt.cn/install/installStable_12.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable_12.sh $url;fi;bash installStable_12.sh 5x59vxy56
|
||||||
|
```
|
||||||
|
|
||||||
|
安全组放行随机端口,浏览器打开(自签名证书提示 → 高级 → 继续前往)。
|
||||||
|
|
||||||
|
### 2. 下载并注册服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/jiang13 && cd /opt/jiang13
|
||||||
|
# 版本号以更新日志为准:https://bbs.iioio.com/page/update-log.html
|
||||||
|
wget -O jiang13 https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64
|
||||||
|
chmod +x jiang13
|
||||||
|
|
||||||
|
# 注册为 systemd 服务(install 创建 unit 文件,start 启动,enable 开机自启)
|
||||||
|
./jiang13 -service install
|
||||||
|
./jiang13 -service start
|
||||||
|
systemctl enable jiang13
|
||||||
|
|
||||||
|
# 日常管理
|
||||||
|
./jiang13 -service status # 查看状态
|
||||||
|
./jiang13 -service restart # 重启
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
curl http://127.0.0.1:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
> 只是临时跑一下?直接 `./jiang13` 前台启动即可,Ctrl+C 停止,不用 `install`。
|
||||||
|
|
||||||
|
### 2.1 升级二进制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/jiang13
|
||||||
|
./jiang13 -service stop
|
||||||
|
wget -O jiang13.new https://git.iioio.com/freefire/jiang13-forum/releases/download/v新版本/jiang13_v新版本_linux-amd64
|
||||||
|
mv jiang13.new jiang13 && chmod +x jiang13
|
||||||
|
./jiang13 -service start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 宝塔反代 + SSL
|
||||||
|
|
||||||
|
网站 → 添加站点(域名,PHP 选**纯静态**)→ 反向代理(目标 `http://127.0.0.1:3000`)→ SSL → Let's Encrypt 一键签发 → 强制 HTTPS。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式三:纯 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Docker
|
||||||
|
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
|
||||||
|
|
||||||
|
# 启动姜十三
|
||||||
|
docker run -d \
|
||||||
|
--name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /opt/jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或用 docker compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
jiang13:
|
||||||
|
image: hangzhang714128/jiang13-forum:latest
|
||||||
|
ports: ["3000:3000"]
|
||||||
|
volumes: ["./data:/data"]
|
||||||
|
environment: {TZ: Asia/Shanghai}
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTPS 用 Caddy 最省心:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbs.yourdomain.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 自动申请 Let's Encrypt。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 域名 + HTTPS
|
||||||
|
|
||||||
|
域名服务商添加 **A 记录**:`bbs` → 服务器公网 IP。
|
||||||
|
|
||||||
|
| 方式 | HTTPS 操作 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel | 网站 → 创建 → 反向代理 → SSL → Let's Encrypt |
|
||||||
|
| 宝塔 | 同上 |
|
||||||
|
| 纯 Docker | Caddy 自动 / Nginx + certbot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据备份
|
||||||
|
|
||||||
|
所有数据(SQLite + 上传 + JWT 密钥)都在**一个目录**里,备份 = 复制目录。
|
||||||
|
|
||||||
|
| 方式 | 数据目录 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel Docker | 你映射的宿主机目录 |
|
||||||
|
| 宝塔二进制 | 二进制同目录下 `data/` |
|
||||||
|
| 纯 Docker | `-v` 映射的目录 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf jiang13-backup-$(date +%Y%m%d).tar.gz /数据目录
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `data/.jwt_secret` 务必一起备份,丢了所有用户已登录的 cookie 会失效,需要重新输入密码登录(密码本身存 SQLite 里不受影响)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本升级
|
||||||
|
|
||||||
|
版本号以 [更新日志](https://bbs.iioio.com/page/update-log.html) 为准。
|
||||||
|
|
||||||
|
| 方式 | 升级 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel | 容器列表 → 选中 → 镜像更新 |
|
||||||
|
| docker compose | `docker compose pull && docker compose up -d` |
|
||||||
|
| 二进制 | 见上方「2.1 升级二进制」 |
|
||||||
|
|
||||||
|
升级前先备份:`tar -czf backup.tar.gz /数据目录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 面板常用命令
|
||||||
|
|
||||||
|
| 操作 | 宝塔 | 1Panel |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 菜单 | `bt` | `1pctl` |
|
||||||
|
| 查入口 / 密码 | `bt default` | `1pctl user-info` |
|
||||||
|
| 改密码 | `bt 14` | `1pctl update password` |
|
||||||
|
| 重启面板 | `bt restart` | — |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q:面板 / 网站打不开?** 安全组有没有放行对应端口?`netstat -tlnp | grep 端口` 看服务在不在监听。
|
||||||
|
|
||||||
|
**Q:宝塔 vs 1Panel 怎么选?** 只跑姜十三 → 宝塔 + 二进制(省资源);还要跑别的 → 1Panel。
|
||||||
|
|
||||||
|
**Q:支持邮件 / SSO 吗?** 邮件 SMTP 和 OIDC SSO 都在**管理后台 → 系统设置**里配置。
|
||||||
|
|
||||||
|
**Q:源码编译?** 见项目 README。推荐用预编译包。
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
| 项目 | 地址 |
|
||||||
|
| --- | --- |
|
||||||
|
| 在线演示 | https://bbs.iioio.com/ |
|
||||||
|
| 更新日志 | https://bbs.iioio.com/page/update-log.html |
|
||||||
|
| Docker Hub | hangzhang714128/jiang13-forum |
|
||||||
|
| 预编译下载 | https://git.iioio.com/freefire/jiang13-forum/releases |
|
||||||
|
|
||||||
|
有问题欢迎本帖留言。
|
||||||
26
docs/site-posts/feedback.md
Normal file
26
docs/site-posts/feedback.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 反馈与建议
|
||||||
|
|
||||||
|
> 建议标题:反馈与建议
|
||||||
|
> 建议标签:`站务` `反馈`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
|
||||||
|
|
||||||
|
## 站内怎么说
|
||||||
|
|
||||||
|
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
|
||||||
|
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
|
||||||
|
- **站务问题**(注册、权限、内容处理):私信管理员
|
||||||
|
|
||||||
|
## 开发仓库(可选)
|
||||||
|
|
||||||
|
若你也参与程序本身:
|
||||||
|
|
||||||
|
- 查路线图、提 Issue / PR:见项目仓库
|
||||||
|
- 许可证:MIT
|
||||||
|
|
||||||
|
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
|
||||||
|
|
||||||
|
感谢一起把小圈子聊好。
|
||||||
10
docs/site-posts/optional.md
Normal file
10
docs/site-posts/optional.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 可选短文(不与介绍长文重复)
|
||||||
|
|
||||||
|
长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。
|
||||||
|
|
||||||
|
若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 |
|
||||||
|
| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package embed_static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"html/template"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed static/*
|
|
||||||
var staticFS embed.FS
|
|
||||||
|
|
||||||
//go:embed templates/*
|
|
||||||
var templatesFS embed.FS
|
|
||||||
|
|
||||||
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
|
|
||||||
func SetupEmbed(r *gin.Engine) error {
|
|
||||||
tmpl, err := LoadTemplates()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.SetHTMLTemplate(tmpl)
|
|
||||||
|
|
||||||
// React SPA 构建产物(Vite)
|
|
||||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
|
||||||
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后台管理遗留静态资源
|
|
||||||
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
|
|
||||||
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeSPA 返回 React SPA 入口
|
|
||||||
func ServeSPA(c *gin.Context) {
|
|
||||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
|
||||||
if err != nil {
|
|
||||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSPARoute 判断是否应由 SPA 处理
|
|
||||||
func IsSPARoute(path string) bool {
|
|
||||||
if strings.HasPrefix(path, "/api") ||
|
|
||||||
strings.HasPrefix(path, "/admin") ||
|
|
||||||
strings.HasPrefix(path, "/uploads") ||
|
|
||||||
strings.HasPrefix(path, "/legacy") ||
|
|
||||||
strings.HasPrefix(path, "/assets") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadTemplates() (*template.Template, error) {
|
|
||||||
sub, err := fs.Sub(templatesFS, "templates")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tmpl := template.New("").Funcs(template.FuncMap{
|
|
||||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
|
||||||
"add": func(a, b int) int { return a + b },
|
|
||||||
"sub": func(a, b int) int { return a - b },
|
|
||||||
})
|
|
||||||
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/* 姜十三论坛全局样式 */
|
|
||||||
:root {
|
|
||||||
--primary: #2c5530;
|
|
||||||
--primary-light: #3d7a44;
|
|
||||||
--accent: #c9a227;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
background: #212529;
|
|
||||||
color: #adb5bd;
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content {
|
|
||||||
line-height: 1.8;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-floor {
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-sm {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-md {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pin {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar .nav-link.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar .nav-link {
|
|
||||||
color: #333;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-slogan {
|
|
||||||
font-style: italic;
|
|
||||||
color: #6c757d;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.navbar-brand span.en { display: none; }
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 姜十三论坛前端通用脚本
|
|
||||||
function apiPost(url, data, isForm) {
|
|
||||||
const opts = { method: 'POST', credentials: 'same-origin' };
|
|
||||||
if (isForm) {
|
|
||||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
|
||||||
if (!(data instanceof HTMLFormElement)) {
|
|
||||||
const fd = new FormData();
|
|
||||||
for (const k in data) fd.append(k, data[k]);
|
|
||||||
opts.body = fd;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
opts.headers = { 'Content-Type': 'application/json' };
|
|
||||||
opts.body = JSON.stringify(data);
|
|
||||||
}
|
|
||||||
return fetch(url, opts).then(r => r.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, type) {
|
|
||||||
const el = document.getElementById('toast');
|
|
||||||
if (!el) { alert(msg); return; }
|
|
||||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
|
||||||
el.querySelector('.toast-body').textContent = msg;
|
|
||||||
new bootstrap.Toast(el).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmAction(msg, callback) {
|
|
||||||
if (confirm(msg)) callback();
|
|
||||||
}
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
/* 姜十三论坛 - 前台 + 管理后台样式 */
|
|
||||||
:root {
|
|
||||||
--primary: #1a7f4b;
|
|
||||||
--primary-dark: #156b3f;
|
|
||||||
--primary-light: #e8f5ee;
|
|
||||||
--accent: #c9a227;
|
|
||||||
--admin-sidebar-w: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
background-color: #f4f6f8;
|
|
||||||
color: #1d2129;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 管理后台 ===== */
|
|
||||||
.admin-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar {
|
|
||||||
height: 56px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 24px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-mark {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255,255,255,.2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-title {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-sub {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: .75;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar-user {
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: .9;
|
|
||||||
padding-right: 4px;
|
|
||||||
border-right: 1px solid rgba(255,255,255,.25);
|
|
||||||
margin-right: 4px;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-shell {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
width: var(--admin-sidebar-w);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: #fff;
|
|
||||||
border-right: 1px solid #e5e6eb;
|
|
||||||
padding: 20px 14px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-section {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #86909c;
|
|
||||||
padding: 12px 12px 6px;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-section:first-child {
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar .nav-link {
|
|
||||||
display: block;
|
|
||||||
color: #4e5969;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar .nav-link:hover {
|
|
||||||
background: #f7f8fa;
|
|
||||||
color: #1d2129;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar .nav-link.active {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
border-left-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 28px 32px;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-width: 1280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-head h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
color: #1d2129;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-head p {
|
|
||||||
margin: 0;
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-search-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-search-bar .form-control {
|
|
||||||
width: 240px;
|
|
||||||
border-color: #e5e6eb;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
transition: box-shadow .2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-icon {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-users .admin-stat-icon { background: #e8f3ff; color: #3491fa; }
|
|
||||||
.admin-stat-posts .admin-stat-icon { background: #e8f5ee; color: var(--primary); }
|
|
||||||
.admin-stat-boards .admin-stat-icon { background: #fff7e8; color: #ff7d00; }
|
|
||||||
.admin-stat-comments .admin-stat-icon { background: #f5e8ff; color: #722ed1; }
|
|
||||||
.admin-stat-online .admin-stat-icon { background: #e8ffea; color: #00b42a; }
|
|
||||||
|
|
||||||
.admin-stat-users .value { color: #3491fa; }
|
|
||||||
.admin-stat-posts .value { color: var(--primary); }
|
|
||||||
.admin-stat-boards .value { color: #ff7d00; }
|
|
||||||
.admin-stat-comments .value { color: #722ed1; }
|
|
||||||
.admin-stat-online .value { color: #00b42a; }
|
|
||||||
|
|
||||||
.admin-stat-card .value {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stat-card .label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #86909c;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card-head {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f2f3f5;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
color: #1d2129;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card-link {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card-link:hover {
|
|
||||||
opacity: .75;
|
|
||||||
color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
--bs-table-hover-bg: #f7f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table th {
|
|
||||||
background: #fafbfc;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #4e5969;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #e5e6eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table td {
|
|
||||||
vertical-align: middle;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #f2f3f5;
|
|
||||||
color: #1d2129;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tbody tr:hover td {
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table .btn-link {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table .btn-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-truncate-cell {
|
|
||||||
max-width: 360px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pin {
|
|
||||||
background: #ff7d00;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-admin {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-banned {
|
|
||||||
background: #ffece8;
|
|
||||||
color: #f53f3f;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-pagination-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #86909c;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .btn-success {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .btn-success:hover {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
border-color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .btn-outline-success {
|
|
||||||
color: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .btn-outline-success:hover {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .form-control:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(26,127,75,.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-body .form-label {
|
|
||||||
color: #4e5969;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(160deg, #f2f3f5 0%, #e8f5ee 60%, #d4edda 100%);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 36px 32px;
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-mark {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto 18px;
|
|
||||||
box-shadow: 0 4px 12px rgba(26,127,75,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-slogan {
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid #f2f3f5;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child { border-bottom: none; }
|
|
||||||
.info-row .info-label { width: 100px; color: #86909c; flex-shrink: 0; }
|
|
||||||
.info-row .info-value { flex: 1; word-break: break-all; color: #1d2129; }
|
|
||||||
|
|
||||||
#adminToast.toast {
|
|
||||||
background: #1d2129;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.admin-stat-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-shell { flex-direction: column; }
|
|
||||||
.admin-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid #e5e6eb;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
.admin-sidebar-section { display: none; }
|
|
||||||
.admin-sidebar .nav-link {
|
|
||||||
padding: 7px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
.admin-sidebar .nav-link.active {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
.admin-main { padding: 16px; max-width: none; }
|
|
||||||
.admin-stat-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.admin-stat-card { padding: 14px 16px; }
|
|
||||||
.admin-stat-card .value { font-size: 22px; }
|
|
||||||
.admin-topbar-user { display: none; }
|
|
||||||
.admin-search-bar .form-control { width: 160px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-stat-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 旧版前台兼容 ===== */
|
|
||||||
.navbar-brand { font-weight: 700; letter-spacing: 0.05em; }
|
|
||||||
.post-content { line-height: 1.8; word-break: break-word; }
|
|
||||||
.post-content img { max-width: 100%; height: auto; }
|
|
||||||
.avatar-sm { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; }
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// 姜十三论坛 - 管理后台通用脚本
|
|
||||||
|
|
||||||
async function adminFetch(url, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
|
||||||
var data;
|
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('服务器响应异常,请重新登录后再试');
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || '请求失败');
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, type) {
|
|
||||||
var el = document.getElementById('adminToast');
|
|
||||||
if (!el) {
|
|
||||||
alert(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
|
||||||
el.querySelector('.toast-body').textContent = msg;
|
|
||||||
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminConfirm(msg, fn) {
|
|
||||||
if (confirm(msg)) fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBtnLoading(btn, loading) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.disabled = loading;
|
|
||||||
if (loading) {
|
|
||||||
btn.dataset.originalHtml = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
|
||||||
} else if (btn.dataset.originalHtml) {
|
|
||||||
btn.innerHTML = btn.dataset.originalHtml;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminLogout() {
|
|
||||||
adminFetch('/admin/api/logout', { method: 'POST' })
|
|
||||||
.then(function () { location.href = '/admin/login'; })
|
|
||||||
.catch(function (e) { showToast(e.message, 'danger'); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function postForm(url, method, fields) {
|
|
||||||
var fd = new FormData();
|
|
||||||
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
|
|
||||||
return adminFetch(url, { method: method, body: fd });
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadSoon() {
|
|
||||||
setTimeout(function () { location.reload(); }, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容旧模板
|
|
||||||
function apiPost(url, data, isForm) {
|
|
||||||
var opts = { method: 'POST', credentials: 'same-origin' };
|
|
||||||
if (isForm) {
|
|
||||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
|
||||||
if (!(data instanceof HTMLFormElement)) {
|
|
||||||
var fd = new FormData();
|
|
||||||
for (var k in data) fd.append(k, data[k]);
|
|
||||||
opts.body = fd;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
opts.headers = { 'Content-Type': 'application/json' };
|
|
||||||
opts.body = JSON.stringify(data);
|
|
||||||
}
|
|
||||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmAction(msg, callback) {
|
|
||||||
adminConfirm(msg, callback);
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{j as n}from"./react-vendor-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function a(n,t){return n.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
|
|
||||||
<div class="post-members-only__badge">
|
|
||||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
||||||
</span>
|
|
||||||
<span>登录可见</span>
|
|
||||||
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`约 ${s} 字的`:"一段";return`
|
|
||||||
<div class="post-members-only__locked-wrap">
|
|
||||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
|
||||||
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
|
|
||||||
<span>登录可见</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-members-only__preview" aria-hidden="true">
|
|
||||||
${n}
|
|
||||||
</div>
|
|
||||||
<div class="post-members-only__gate">
|
|
||||||
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
|
|
||||||
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
|
|
||||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
|
||||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
|
||||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
|
||||||
</div>
|
|
||||||
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>姜十三论坛 Jiang13 Forum</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; }
|
|
||||||
.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>
|
|
||||||
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
{{define "admin/boards.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_boards"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>板块管理</h1>
|
|
||||||
<p>创建和维护论坛板块,有帖子的板块无法删除</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-head">新建板块</div>
|
|
||||||
<div class="admin-card-body">
|
|
||||||
<form id="createBoardForm" class="row g-2 align-items-end">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">板块名称</label>
|
|
||||||
<input name="name" class="form-control" placeholder="如:技术交流" required maxlength="64">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label small">简介</label>
|
|
||||||
<input name="description" class="form-control" placeholder="板块说明(可选)" maxlength="500">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">排序</label>
|
|
||||||
<input name="sort_order" type="number" class="form-control" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="submit" class="btn btn-success w-100" id="createBoardBtn">创建板块</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-head">板块列表 <span class="text-muted fw-normal">共 {{len .Boards}} 个</span></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table admin-table mb-0">
|
|
||||||
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>排序</th><th>帖子数</th><th width="160">操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Boards}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.ID}}</td>
|
|
||||||
<td><input class="form-control form-control-sm" id="name-{{.ID}}" value="{{.Name}}"></td>
|
|
||||||
<td><input class="form-control form-control-sm" id="desc-{{.ID}}" value="{{.Description}}"></td>
|
|
||||||
<td><input class="form-control form-control-sm" id="sort-{{.ID}}" type="number" value="{{.SortOrder}}" style="width:72px"></td>
|
|
||||||
<td><span class="badge bg-secondary">{{.PostCount}}</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn-sm btn-primary" onclick="updateBoard({{.ID}}, this)">保存</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteBoard({{.ID}}, {{.PostCount}})">删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="6" class="admin-empty">还没有板块,请先创建</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_boards"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('createBoardForm').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var btn = document.getElementById('createBoardBtn');
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
adminFetch('/admin/api/boards', { method: 'POST', body: new FormData(this) })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
|
||||||
.finally(function() { setBtnLoading(btn, false); });
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateBoard(id, btn) {
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
postForm('/admin/api/boards/' + id, 'PUT', {
|
|
||||||
name: document.getElementById('name-' + id).value,
|
|
||||||
description: document.getElementById('desc-' + id).value,
|
|
||||||
sort_order: document.getElementById('sort-' + id).value
|
|
||||||
}).then(function(d) { showToast(d.message); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
|
||||||
.finally(function() { setBtnLoading(btn, false); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteBoard(id, postCount) {
|
|
||||||
if (postCount > 0) {
|
|
||||||
showToast('该板块下还有 ' + postCount + ' 篇帖子,无法删除', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
adminConfirm('确定删除该板块?此操作不可恢复。', function() {
|
|
||||||
adminFetch('/admin/api/boards/' + id, { method: 'DELETE' })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{{define "admin/comments.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_comments"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>评论管理</h1>
|
|
||||||
<p>查看和删除评论,共 {{.Total}} 条</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table admin-table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>ID</th><th>楼层</th><th>帖子</th><th>作者</th><th>回复</th><th>内容</th><th>时间</th><th width="100">操作</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Comments}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.ID}}</td>
|
|
||||||
<td>#{{.Floor}}</td>
|
|
||||||
<td class="text-truncate-cell">
|
|
||||||
{{if .Post}}
|
|
||||||
<a href="/post/{{.PostID}}" target="_blank">{{.Post.Title}}</a>
|
|
||||||
{{else}}帖子 #{{.PostID}}{{end}}
|
|
||||||
</td>
|
|
||||||
<td>{{if .UserID}}{{if .User}}{{.User.Nickname}}{{else}}-{{end}}{{else}}{{.GuestNick}}{{end}}</td>
|
|
||||||
<td>
|
|
||||||
{{if .ReplyTarget}}
|
|
||||||
@{{if .ReplyTarget.UserID}}{{if .ReplyTarget.User}}{{.ReplyTarget.User.Nickname}}{{else}}-{{end}}{{else}}{{.ReplyTarget.GuestNick}}{{end}}
|
|
||||||
{{else}}-{{end}}
|
|
||||||
</td>
|
|
||||||
<td class="text-truncate-cell">{{.Content}}</td>
|
|
||||||
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteComment({{.ID}})">删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="8" class="admin-empty">暂无评论</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{template "admin_pagination" .}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_comments"}}
|
|
||||||
<script>
|
|
||||||
function deleteComment(id) {
|
|
||||||
adminConfirm('确定删除该评论?', function() {
|
|
||||||
adminFetch('/admin/api/comments/' + id, { method: 'DELETE' })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{{define "admin/dashboard.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_dashboard"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>仪表盘</h1>
|
|
||||||
<p>欢迎回来{{if .CurrentUser}},{{.CurrentUser.Nickname}}{{end}} · 论坛运行概况</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-stat-grid">
|
|
||||||
<div class="admin-stat-card admin-stat-users">
|
|
||||||
<div class="admin-stat-icon">用</div>
|
|
||||||
<div class="admin-stat-info">
|
|
||||||
<div class="value">{{.UserCount}}</div>
|
|
||||||
<div class="label">注册用户</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-stat-card admin-stat-posts">
|
|
||||||
<div class="admin-stat-icon">帖</div>
|
|
||||||
<div class="admin-stat-info">
|
|
||||||
<div class="value">{{.PostCount}}</div>
|
|
||||||
<div class="label">帖子总数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-stat-card admin-stat-boards">
|
|
||||||
<div class="admin-stat-icon">板</div>
|
|
||||||
<div class="admin-stat-info">
|
|
||||||
<div class="value">{{.BoardCount}}</div>
|
|
||||||
<div class="label">板块数量</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-stat-card admin-stat-comments">
|
|
||||||
<div class="admin-stat-icon">评</div>
|
|
||||||
<div class="admin-stat-info">
|
|
||||||
<div class="value">{{.CommentCount}}</div>
|
|
||||||
<div class="label">评论总数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-stat-card admin-stat-online">
|
|
||||||
<div class="admin-stat-icon">线</div>
|
|
||||||
<div class="admin-stat-info">
|
|
||||||
<div class="value">{{.OnlineCount}}</div>
|
|
||||||
<div class="label">当前浏览</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-head">
|
|
||||||
<span>最近帖子</span>
|
|
||||||
<a href="/admin/posts" class="admin-card-link">查看全部 →</a>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table admin-table mb-0">
|
|
||||||
<thead><tr><th>标题</th><th>板块</th><th>作者</th><th>时间</th><th width="80"></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .RecentPosts}}
|
|
||||||
<tr>
|
|
||||||
<td class="text-truncate-cell">
|
|
||||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Title}}
|
|
||||||
</td>
|
|
||||||
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
|
||||||
<td class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</td>
|
|
||||||
<td><a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link px-0">查看</a></td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="5" class="admin-empty">暂无帖子</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_dashboard"}}{{end}}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
{{define "admin/layout"}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{{.Title}} - 姜十三论坛管理后台</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/legacy/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="admin-body">
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index:9999">
|
|
||||||
<div id="adminToast" class="toast" role="alert">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="toast-body"></div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header class="admin-topbar">
|
|
||||||
<div class="admin-topbar-brand">
|
|
||||||
<div class="admin-topbar-mark">姜</div>
|
|
||||||
<div>
|
|
||||||
<span class="admin-topbar-title">姜十三论坛</span>
|
|
||||||
<span class="admin-topbar-sub">管理后台</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-topbar-actions">
|
|
||||||
{{if .CurrentUser}}
|
|
||||||
<span class="admin-topbar-user">{{.CurrentUser.Nickname}}</span>
|
|
||||||
{{end}}
|
|
||||||
<a href="/" class="btn btn-outline-light btn-sm">返回前台</a>
|
|
||||||
<button class="btn btn-light btn-sm" onclick="adminLogout()">退出</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="admin-shell">
|
|
||||||
<aside class="admin-sidebar">
|
|
||||||
<div class="admin-sidebar-section">概览</div>
|
|
||||||
<a href="/admin/dashboard" class="nav-link {{if eq .ActiveNav "dashboard"}}active{{end}}">仪表盘</a>
|
|
||||||
|
|
||||||
<div class="admin-sidebar-section">内容</div>
|
|
||||||
<a href="/admin/boards" class="nav-link {{if eq .ActiveNav "boards"}}active{{end}}">板块管理</a>
|
|
||||||
<a href="/admin/posts" class="nav-link {{if eq .ActiveNav "posts"}}active{{end}}">帖子管理</a>
|
|
||||||
<a href="/admin/comments" class="nav-link {{if eq .ActiveNav "comments"}}active{{end}}">评论管理</a>
|
|
||||||
|
|
||||||
<div class="admin-sidebar-section">系统</div>
|
|
||||||
<a href="/admin/users" class="nav-link {{if eq .ActiveNav "users"}}active{{end}}">用户管理</a>
|
|
||||||
<a href="/admin/settings" class="nav-link {{if eq .ActiveNav "settings"}}active{{end}}">系统设置</a>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="admin-main">
|
|
||||||
{{if eq .ActiveNav "dashboard"}}{{template "admin_content_dashboard" .}}
|
|
||||||
{{else if eq .ActiveNav "boards"}}{{template "admin_content_boards" .}}
|
|
||||||
{{else if eq .ActiveNav "posts"}}{{template "admin_content_posts" .}}
|
|
||||||
{{else if eq .ActiveNav "comments"}}{{template "admin_content_comments" .}}
|
|
||||||
{{else if eq .ActiveNav "users"}}{{template "admin_content_users" .}}
|
|
||||||
{{else if eq .ActiveNav "settings"}}{{template "admin_content_settings" .}}
|
|
||||||
{{end}}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/legacy/js/app.js"></script>
|
|
||||||
{{if eq .ActiveNav "boards"}}{{template "admin_scripts_boards" .}}
|
|
||||||
{{else if eq .ActiveNav "posts"}}{{template "admin_scripts_posts" .}}
|
|
||||||
{{else if eq .ActiveNav "comments"}}{{template "admin_scripts_comments" .}}
|
|
||||||
{{else if eq .ActiveNav "users"}}{{template "admin_scripts_users" .}}
|
|
||||||
{{else if eq .ActiveNav "settings"}}{{template "admin_scripts_settings" .}}
|
|
||||||
{{end}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_pagination"}}
|
|
||||||
{{if gt .TotalPages 1}}
|
|
||||||
<nav class="admin-pagination">
|
|
||||||
{{if gt .Page 1}}
|
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="?page={{sub .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">上一页</a>
|
|
||||||
{{end}}
|
|
||||||
<span class="admin-pagination-info">第 {{.Page}} / {{.TotalPages}} 页</span>
|
|
||||||
{{if lt .Page .TotalPages}}
|
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="?page={{add .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">下一页</a>
|
|
||||||
{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{{define "admin/login.html"}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>后台登录 - 姜十三论坛</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/legacy/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="admin-login-page">
|
|
||||||
<div class="admin-login-card">
|
|
||||||
<div class="admin-login-mark">姜</div>
|
|
||||||
<h4 class="text-center mb-1">管理后台登录</h4>
|
|
||||||
<p class="login-slogan text-center mb-4">姜十三论坛 · 仅管理员可访问</p>
|
|
||||||
{{if eq .QueryBanned "1"}}
|
|
||||||
<div class="alert alert-warning small py-2">账号已被禁言,无法登录后台</div>
|
|
||||||
{{end}}
|
|
||||||
<form id="adminLoginForm">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small">管理员账号</label>
|
|
||||||
<input type="text" name="username" class="form-control" placeholder="用户名" required autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small">密码</label>
|
|
||||||
<input type="password" name="password" class="form-control" placeholder="密码" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success w-100" id="loginBtn">登录</button>
|
|
||||||
</form>
|
|
||||||
<p class="text-center mt-3 mb-0 small">
|
|
||||||
<a href="/" class="text-muted">← 返回论坛前台</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/legacy/js/app.js"></script>
|
|
||||||
<script>
|
|
||||||
document.getElementById('adminLoginForm').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var btn = document.getElementById('loginBtn');
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
adminFetch('/admin/api/login', { method: 'POST', body: new FormData(this) })
|
|
||||||
.then(function() { location.href = '/admin/dashboard'; })
|
|
||||||
.catch(function(err) { alert(err.message); })
|
|
||||||
.finally(function() { setBtnLoading(btn, false); });
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
{{define "admin/posts.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_posts"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>帖子管理</h1>
|
|
||||||
<p>置顶、删除帖子,共 {{.Total}} 篇</p>
|
|
||||||
</div>
|
|
||||||
<form class="admin-search-bar" method="get">
|
|
||||||
<input name="keyword" class="form-control form-control-sm" placeholder="搜索标题/内容..." value="{{.Keyword}}">
|
|
||||||
<button class="btn btn-sm btn-success">搜索</button>
|
|
||||||
{{if .Keyword}}<a href="/admin/posts" class="btn btn-sm btn-outline-secondary">清除</a>{{end}}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table admin-table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th><th>标题</th><th>板块</th><th>作者</th>
|
|
||||||
<th>置顶</th><th>点赞</th><th>浏览</th><th>时间</th><th width="180">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Posts}}
|
|
||||||
<tr id="post-row-{{.ID}}">
|
|
||||||
<td>{{.ID}}</td>
|
|
||||||
<td class="text-truncate-cell">{{.Title}}</td>
|
|
||||||
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{if .Pinned}}<span class="badge badge-pin">是</span>{{else}}<span class="text-muted">否</span>{{end}}</td>
|
|
||||||
<td>{{.LikeCount}}</td>
|
|
||||||
<td>{{.ViewCount}}</td>
|
|
||||||
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
|
||||||
<td>
|
|
||||||
<a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link">查看</a>
|
|
||||||
<button class="btn btn-sm btn-outline-warning" onclick="togglePin({{.ID}}, {{.Pinned}})">{{if .Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePost({{.ID}})">删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="9" class="admin-empty">没有找到帖子</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{template "admin_pagination" .}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_posts"}}
|
|
||||||
<script>
|
|
||||||
function togglePin(id, pinned) {
|
|
||||||
postForm('/admin/api/posts/' + id + '/pin', 'POST', { pinned: pinned ? 'false' : 'true' })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePost(id) {
|
|
||||||
adminConfirm('确定删除该帖子?相关评论也将一并删除。', function() {
|
|
||||||
adminFetch('/admin/api/posts/' + id, { method: 'DELETE' })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{{define "admin/settings.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_settings"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>系统设置</h1>
|
|
||||||
<p>数据目录、敏感词配置与数据库备份</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-head">运行信息</div>
|
|
||||||
<div class="admin-card-body">
|
|
||||||
<div class="info-row"><div class="info-label">数据目录</div><div class="info-value"><code>{{.DataDir}}</code></div></div>
|
|
||||||
<div class="info-row"><div class="info-label">数据库文件</div><div class="info-value"><code>{{.DBPath}}</code></div></div>
|
|
||||||
<div class="info-row"><div class="info-label">监听端口</div><div class="info-value">{{.Port}}</div></div>
|
|
||||||
<div class="info-row"><div class="info-label">敏感词配置</div><div class="info-value"><code>{{.FilterPath}}</code></div></div>
|
|
||||||
<p class="text-muted small mt-3 mb-0">敏感词文件每行一个词,<code>#</code> 开头为注释,修改后需重启服务生效。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-head">数据库备份</div>
|
|
||||||
<div class="admin-card-body">
|
|
||||||
<p class="text-muted small">将 SQLite 数据库复制到数据目录,生成带时间戳的备份文件。</p>
|
|
||||||
<button class="btn btn-success" id="backupBtn" onclick="doBackup()">一键导出备份</button>
|
|
||||||
<div id="backupResult" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_settings"}}
|
|
||||||
<script>
|
|
||||||
function doBackup() {
|
|
||||||
var btn = document.getElementById('backupBtn');
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
adminFetch('/admin/api/backup', { method: 'POST' })
|
|
||||||
.then(function(d) {
|
|
||||||
showToast(d.message);
|
|
||||||
var html = '<div class="alert alert-success small mb-0">';
|
|
||||||
html += '<div>备份文件:<code>' + d.filename + '</code></div>';
|
|
||||||
if (d.download) {
|
|
||||||
html += '<a href="' + d.download + '" class="btn btn-sm btn-outline-success mt-2">下载备份文件</a>';
|
|
||||||
}
|
|
||||||
html += '<div class="text-muted mt-2">存储路径:' + d.path + '</div></div>';
|
|
||||||
document.getElementById('backupResult').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
|
||||||
.finally(function() { setBtnLoading(btn, false); });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{{define "admin/users.html"}}{{template "admin/layout" .}}{{end}}
|
|
||||||
{{define "admin_content_users"}}
|
|
||||||
<div class="admin-page-head">
|
|
||||||
<div>
|
|
||||||
<h1>用户管理</h1>
|
|
||||||
<p>禁言违规用户,共 {{.Total}} 位注册用户</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table admin-table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Users}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.ID}}</td>
|
|
||||||
<td>{{.Username}}</td>
|
|
||||||
<td>{{.Nickname}}</td>
|
|
||||||
<td>
|
|
||||||
{{if eq .Role "admin"}}
|
|
||||||
<span class="badge badge-admin">管理员</span>
|
|
||||||
{{else}}普通用户{{end}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
|
|
||||||
</td>
|
|
||||||
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
|
||||||
<td>
|
|
||||||
{{if eq .Role "admin"}}
|
|
||||||
<span class="text-muted small">—</span>
|
|
||||||
{{else if .Banned}}
|
|
||||||
<button class="btn btn-sm btn-success" onclick="banUser({{.ID}}, false)">解除禁言</button>
|
|
||||||
{{else}}
|
|
||||||
<button class="btn btn-sm btn-outline-warning" onclick="banUser({{.ID}}, true)">禁言</button>
|
|
||||||
{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{template "admin_pagination" .}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "admin_scripts_users"}}
|
|
||||||
<script>
|
|
||||||
function banUser(id, banned) {
|
|
||||||
var msg = banned ? '确定禁言该用户?禁言后无法登录和发帖。' : '确定解除该用户的禁言?';
|
|
||||||
adminConfirm(msg, function() {
|
|
||||||
postForm('/admin/api/users/' + id + '/ban', 'POST', { banned: banned ? 'true' : 'false' })
|
|
||||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
|
||||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{{define "board.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item active">{{.Board.Name}}</li></ol></nav>
|
|
||||||
<h3>{{.Board.Name}}</h3>
|
|
||||||
<p class="text-muted">{{.Board.Description}}</p>
|
|
||||||
{{if .CurrentUser}}<a href="/post/new?board={{.Board.ID}}" class="btn btn-success btn-sm mb-3">在此板块发帖</a>{{end}}
|
|
||||||
{{range .Posts}}
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-body py-2">
|
|
||||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
|
||||||
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
|
||||||
<div class="small text-muted mt-1">{{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}<p class="text-muted">该板块暂无帖子</p>{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{define "error.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<h3>{{.Message}}</h3>
|
|
||||||
<a href="/" class="btn btn-primary mt-3">返回首页</a>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{{define "favorites.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<h3>我的收藏</h3>
|
|
||||||
{{range .Favorites}}
|
|
||||||
<div class="card mb-2"><div class="card-body py-2">
|
|
||||||
<a href="/post/{{.Post.ID}}" class="fw-semibold text-decoration-none">{{.Post.Title}}</a>
|
|
||||||
<div class="small text-muted">{{.Post.Board.Name}} · 收藏于 {{.CreatedAt.Format "2006-01-02"}}</div>
|
|
||||||
</div></div>
|
|
||||||
{{else}}<p class="text-muted">暂无收藏</p>{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{{define "index.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<h4 class="mb-3">最新帖子</h4>
|
|
||||||
{{range .Posts}}
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-body py-2">
|
|
||||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
|
||||||
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
{{.Board.Name}} · {{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}} · 👁 {{.ViewCount}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<p class="text-muted">暂无帖子,快来发帖吧!</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<h5>板块列表</h5>
|
|
||||||
<div class="list-group">
|
|
||||||
{{range .Boards}}
|
|
||||||
<a href="/board/{{.ID}}" class="list-group-item list-group-item-action">
|
|
||||||
<strong>{{.Name}}</strong>
|
|
||||||
<small class="d-block text-muted">{{.Description}}</small>
|
|
||||||
</a>
|
|
||||||
{{else}}
|
|
||||||
<p class="text-muted small">管理员尚未创建板块</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{{define "layout"}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{{.Title}} - 姜十三论坛</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/assets/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark" style="background:var(--primary)">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="/">姜十三论坛 <span class="en fs-6 opacity-75">Jiang13 Forum</span></a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="nav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
|
|
||||||
{{if .CurrentUser}}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/post/new">发帖</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/favorites">我的收藏</a></li>
|
|
||||||
{{end}}
|
|
||||||
{{if .IsAdmin}}<li class="nav-item"><a class="nav-link" href="/admin/dashboard">管理后台</a></li>{{end}}
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
{{if .CurrentUser}}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/profile">{{.CurrentUser.Nickname}}</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">退出</a></li>
|
|
||||||
{{else}}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/login">登录</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/register">注册</a></li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<main class="container py-4 flex-grow-1">
|
|
||||||
{{template "content" .}}
|
|
||||||
</main>
|
|
||||||
<footer class="site-footer text-center">
|
|
||||||
<div class="container">© 2026 姜十三论坛 Jiang13 Forum · 拾三一隅,自在交流</div>
|
|
||||||
</footer>
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
|
||||||
<div id="toast" class="toast" role="alert"><div class="toast-body"></div></div>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
<script>
|
|
||||||
function logout(){ fetch('/api/logout',{method:'POST',credentials:'same-origin'}).then(()=>location.href='/'); }
|
|
||||||
</script>
|
|
||||||
{{block "scripts" .}}{{end}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{{define "login.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-5">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<h3 class="text-center mb-1">登录</h3>
|
|
||||||
<p class="text-center login-slogan mb-4">拾三一隅,自在交流</p>
|
|
||||||
<form id="loginForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">用户名</label>
|
|
||||||
<input type="text" name="username" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">密码</label>
|
|
||||||
<input type="password" name="password" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success w-100">登录</button>
|
|
||||||
</form>
|
|
||||||
<p class="text-center mt-3 mb-0">还没有账号?<a href="/register">立即注册</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', function(e){
|
|
||||||
e.preventDefault();
|
|
||||||
apiPost('/api/login', this, true).then(d=>{
|
|
||||||
if(d.error){ showToast(d.error,'danger'); return; }
|
|
||||||
location.href='/';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{{define "post.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item"><a href="/board/{{.Post.BoardID}}">{{.Post.Board.Name}}</a></li><li class="breadcrumb-item active">帖子</li></ol></nav>
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3>{{if .Post.Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Post.Title}}</h3>
|
|
||||||
<div class="small text-muted mb-3">
|
|
||||||
<a href="/user/{{.Post.UserID}}">{{.Post.User.Nickname}}</a> · {{.Post.CreatedAt.Format "2006-01-02 15:04"}} · 👁 {{.Post.ViewCount}}
|
|
||||||
{{if .Post.Tags}}· 标签:{{.Post.Tags}}{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="post-content">{{safeHTML .Post.Content}}</div>
|
|
||||||
<div class="mt-3 d-flex gap-2 flex-wrap">
|
|
||||||
{{if .CurrentUser}}
|
|
||||||
<button class="btn btn-outline-primary btn-sm" id="likeBtn" onclick="toggleLike()">{{if .Liked}}已点赞{{else}}点赞{{end}}</button>
|
|
||||||
<button class="btn btn-outline-warning btn-sm" id="favBtn" onclick="toggleFav()">{{if .Favorited}}已收藏{{else}}收藏{{end}}</button>
|
|
||||||
{{if or (eq .CurrentUserID .Post.UserID) .IsAdmin}}
|
|
||||||
<a href="/post/{{.Post.ID}}/edit" class="btn btn-outline-secondary btn-sm">编辑</a>
|
|
||||||
<button class="btn btn-outline-danger btn-sm" onclick="deletePost()">删除</button>
|
|
||||||
{{end}}
|
|
||||||
{{if .IsAdmin}}
|
|
||||||
<button class="btn btn-outline-dark btn-sm" onclick="pinPost()">{{if .Post.Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
|
||||||
{{end}}
|
|
||||||
{{else}}<a href="/login" class="btn btn-outline-primary btn-sm">登录后互动</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h5>评论 ({{len .Comments}})</h5>
|
|
||||||
{{range .Comments}}
|
|
||||||
<div class="card mb-2 comment-floor">
|
|
||||||
<div class="card-body py-2">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<strong>#{{.Floor}} {{.User.Nickname}}</strong>
|
|
||||||
<small class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</small>
|
|
||||||
</div>
|
|
||||||
{{if .ReplyUser}}<div class="small text-muted">回复 #{{.ReplyUser.Floor}} {{.ReplyUser.User.Nickname}}</div>{{end}}
|
|
||||||
<div class="mt-1">{{.Content}}</div>
|
|
||||||
{{if $.CurrentUser}}
|
|
||||||
<button class="btn btn-link btn-sm p-0" onclick="replyTo({{.ID}},{{.Floor}},'{{.User.Nickname}}')">回复</button>
|
|
||||||
{{if or (eq $.CurrentUserID .UserID) $.IsAdmin}}
|
|
||||||
<button class="btn btn-link btn-sm p-0 text-danger" onclick="deleteComment({{.ID}})">删除</button>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .CurrentUser}}
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="commentForm">
|
|
||||||
<div id="replyHint" class="small text-muted mb-2" style="display:none"></div>
|
|
||||||
<input type="hidden" name="reply_to" id="replyTo">
|
|
||||||
<textarea name="content" class="form-control mb-2" rows="3" placeholder="写下你的评论..." required></textarea>
|
|
||||||
<button type="submit" class="btn btn-success btn-sm">发表评论</button>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelReply()" id="cancelReplyBtn" style="display:none">取消回复</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
const postId={{.Post.ID}};
|
|
||||||
function toggleLike(){ fetch('/api/posts/'+postId+'/like',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('likeBtn').textContent=d.liked?'已点赞':'点赞'; }); }
|
|
||||||
function toggleFav(){ fetch('/api/posts/'+postId+'/favorite',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('favBtn').textContent=d.favorited?'已收藏':'收藏'; }); }
|
|
||||||
function deletePost(){ confirmAction('确定删除此帖?',()=>{ fetch('/api/posts/'+postId,{method:'DELETE',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.href='/board/{{.Post.BoardID}}'; }); }); }
|
|
||||||
function pinPost(){ fetch('/admin/api/posts/'+postId+'/pin',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pinned={{if .Post.Pinned}}false{{else}}true{{end}}'}).then(r=>r.json()).then(()=>location.reload()); }
|
|
||||||
function replyTo(id,floor,name){ document.getElementById('replyTo').value=id; document.getElementById('replyHint').style.display='block'; document.getElementById('replyHint').textContent='回复 #'+floor+' '+name; document.getElementById('cancelReplyBtn').style.display='inline-block'; }
|
|
||||||
function cancelReply(){ document.getElementById('replyTo').value=''; document.getElementById('replyHint').style.display='none'; document.getElementById('cancelReplyBtn').style.display='none'; }
|
|
||||||
function deleteComment(id){ confirmAction('确定删除?',()=>{ fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin'}).then(()=>location.reload()); }); }
|
|
||||||
document.getElementById('commentForm')?.addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/posts/'+postId+'/comments',this,true).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{{define "post_edit.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<h3>编辑帖子</h3>
|
|
||||||
<form id="editForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">标题</label>
|
|
||||||
<input type="text" name="title" class="form-control" value="{{.Post.Title}}" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">标签</label>
|
|
||||||
<input type="text" name="tags" class="form-control" value="{{.Post.Tags}}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">内容</label>
|
|
||||||
<textarea name="content" class="form-control" rows="12" required>{{.Post.Content}}</textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">保存</button>
|
|
||||||
<a href="/post/{{.Post.ID}}" class="btn btn-secondary">取消</a>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('editForm').addEventListener('submit',function(e){
|
|
||||||
e.preventDefault();
|
|
||||||
fetch('/api/posts/{{.Post.ID}}',{method:'PUT',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{
|
|
||||||
if(d.error){showToast(d.error,'danger');return;}
|
|
||||||
location.href='/post/{{.Post.ID}}';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{{define "post_new.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<h3>发布新帖</h3>
|
|
||||||
<form id="postForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">选择板块</label>
|
|
||||||
<select name="board_id" class="form-select" required>
|
|
||||||
{{range .Boards}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">标题</label>
|
|
||||||
<input type="text" name="title" class="form-control" required maxlength="256">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">标签(逗号分隔)</label>
|
|
||||||
<input type="text" name="tags" class="form-control" placeholder="Go,技术,交流">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">内容(支持 HTML 富文本)</label>
|
|
||||||
<textarea name="content" class="form-control" rows="12" required></textarea>
|
|
||||||
<div class="form-text">可使用 <b>、<i>、<a>、<img> 等 HTML 标签</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">发布</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('postForm').addEventListener('submit',function(e){
|
|
||||||
e.preventDefault();
|
|
||||||
apiPost('/api/posts',this,true).then(d=>{
|
|
||||||
if(d.error){showToast(d.error,'danger');return;}
|
|
||||||
location.href='/post/'+d.post_id;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{{define "profile.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 text-center mb-4">
|
|
||||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
|
|
||||||
<h4>{{.ProfileUser.Nickname}}</h4>
|
|
||||||
<p class="text-muted">@{{.ProfileUser.Username}}</p>
|
|
||||||
<form id="avatarForm" enctype="multipart/form-data">
|
|
||||||
<input type="file" name="avatar" accept="image/*" class="form-control form-control-sm mb-2">
|
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm">更换头像</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card mb-3"><div class="card-header">修改昵称</div><div class="card-body">
|
|
||||||
<form id="nickForm"><input type="text" name="nickname" class="form-control mb-2" value="{{.ProfileUser.Nickname}}">
|
|
||||||
<button class="btn btn-success btn-sm">保存昵称</button></form>
|
|
||||||
</div></div>
|
|
||||||
<div class="card"><div class="card-header">修改密码</div><div class="card-body">
|
|
||||||
<form id="pwdForm">
|
|
||||||
<input type="password" name="old_password" class="form-control mb-2" placeholder="原密码" required>
|
|
||||||
<input type="password" name="new_password" class="form-control mb-2" placeholder="新密码(至少6位)" required>
|
|
||||||
<button class="btn btn-success btn-sm">修改密码</button>
|
|
||||||
</form>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('nickForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/nickname',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
|
||||||
document.getElementById('pwdForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/password',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
|
||||||
document.getElementById('avatarForm').addEventListener('submit',function(e){ e.preventDefault(); fetch('/api/profile/avatar',{method:'POST',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{{define "register.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-5">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<h3 class="text-center mb-4">注册账号</h3>
|
|
||||||
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
|
|
||||||
<form id="regForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">用户名(3-32位字母数字下划线)</label>
|
|
||||||
<input type="text" name="username" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">昵称</label>
|
|
||||||
<input type="text" name="nickname" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">密码(至少6位)</label>
|
|
||||||
<input type="password" name="password" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success w-100">注册</button>
|
|
||||||
</form>
|
|
||||||
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{define "scripts"}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('regForm').addEventListener('submit', function(e){
|
|
||||||
e.preventDefault();
|
|
||||||
apiPost('/api/register', this, true).then(d=>{
|
|
||||||
if(d.error){ showToast(d.error,'danger'); return; }
|
|
||||||
showToast('注册成功'); location.href='/';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{{define "user_profile.html"}}{{template "layout" .}}{{end}}
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2">
|
|
||||||
<h3>{{.ProfileUser.Nickname}}</h3>
|
|
||||||
<p class="text-muted">@{{.ProfileUser.Username}} · 注册于 {{.ProfileUser.CreatedAt.Format "2006-01-02"}}</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "",
|
||||||
"css": "src/styles/global.css",
|
"css": "src/styles/global.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>姜十三论坛 Jiang13 Forum</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; }
|
|
||||||
.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>
|
|
||||||
6227
frontend/package-lock.json
generated
6227
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vike dev",
|
||||||
"build": "vite build",
|
"build": "vike build",
|
||||||
"preview": "vite preview"
|
"preview": "vike preview",
|
||||||
|
"start": "node server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
@@ -16,31 +20,57 @@
|
|||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@svg-maps/china": "^2.0.0",
|
||||||
"autoprefixer": "^10.5.0",
|
"@svg-maps/world": "^2.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@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",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"http-proxy-middleware": "^4.2.0",
|
||||||
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"postcss": "^8.5.15",
|
"react": "^19.2.8",
|
||||||
"react": "^18.3.1",
|
"react-dom": "^19.2.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-easy-crop": "^6.0.2",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router": "^8.3.1",
|
||||||
|
"react-streaming": "^0.4.20",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.3.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"turndown": "^7.2.4",
|
||||||
"zod": "^4.4.3"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vike": "^0.4.266",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@types/turndown": "^5.0.6",
|
||||||
"typescript": "^5.6.3",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"vite": "^5.4.11"
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/pages/+config.ts
Normal file
12
frontend/pages/+config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Config } from 'vike/types';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
|
||||||
|
passToClient: ['pageProps', 'data'],
|
||||||
|
// 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
|
||||||
|
meta: {
|
||||||
|
ssr: {
|
||||||
|
env: { server: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
10
frontend/pages/_error/+Page.tsx
Normal file
10
frontend/pages/_error/+Page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import App from '../../src/App';
|
||||||
|
import type { PageContext } from 'vike/types';
|
||||||
|
|
||||||
|
export { Page };
|
||||||
|
|
||||||
|
/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */
|
||||||
|
function Page(pageContext: PageContext & { url?: string }) {
|
||||||
|
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
|
||||||
|
return <App url={url} />;
|
||||||
|
}
|
||||||
24
frontend/pages/_error/+data.ts
Normal file
24
frontend/pages/_error/+data.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PageContextServer } from 'vike/types';
|
||||||
|
import type { FeedData } from '../../src/ssr/feedSeed';
|
||||||
|
|
||||||
|
export { data };
|
||||||
|
|
||||||
|
type NotFoundAbortReason = {
|
||||||
|
head?: FeedData['head'];
|
||||||
|
boot?: FeedData['boot'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }),
|
||||||
|
* 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。
|
||||||
|
*/
|
||||||
|
async function data(pageContext: PageContextServer): Promise<FeedData> {
|
||||||
|
if (!import.meta.env.SSR) return { kind: 'notfound' };
|
||||||
|
|
||||||
|
const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
|
||||||
|
return {
|
||||||
|
kind: 'notfound',
|
||||||
|
head: reason?.head,
|
||||||
|
boot: reason?.boot,
|
||||||
|
};
|
||||||
|
}
|
||||||
11
frontend/pages/feed/+Page.tsx
Normal file
11
frontend/pages/feed/+Page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import App from '../../src/App';
|
||||||
|
import type { PageContext } from 'vike/types';
|
||||||
|
|
||||||
|
export { Page };
|
||||||
|
|
||||||
|
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
|
||||||
|
function Page(pageContext: PageContext & { url?: string }) {
|
||||||
|
// onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
|
||||||
|
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
|
||||||
|
return <App url={url} />;
|
||||||
|
}
|
||||||
9
frontend/pages/feed/+data.ts
Normal file
9
frontend/pages/feed/+data.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PageContextServer } from 'vike/types';
|
||||||
|
import { loadFeedData } from '../../src/ssr/feedData';
|
||||||
|
|
||||||
|
export { data };
|
||||||
|
|
||||||
|
// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建
|
||||||
|
async function data(pageContext: PageContextServer) {
|
||||||
|
return await loadFeedData(pageContext);
|
||||||
|
}
|
||||||
11
frontend/pages/feed/+route.ts
Normal file
11
frontend/pages/feed/+route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PageContext } from 'vike/types';
|
||||||
|
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
|
||||||
|
|
||||||
|
export { route };
|
||||||
|
|
||||||
|
/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */
|
||||||
|
function route(pageContext: PageContext) {
|
||||||
|
const match = matchFeedSsrPath(pageContext.urlPathname);
|
||||||
|
if (!match) return false;
|
||||||
|
return { routeParams: { kind: match.kind, id: match.id } };
|
||||||
|
}
|
||||||
8
frontend/pages/spa/+Page.tsx
Normal file
8
frontend/pages/spa/+Page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import App from '../../src/App';
|
||||||
|
|
||||||
|
export { Page };
|
||||||
|
|
||||||
|
/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
|
||||||
|
function Page() {
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
10
frontend/pages/spa/+route.ts
Normal file
10
frontend/pages/spa/+route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PageContext } from 'vike/types';
|
||||||
|
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
|
||||||
|
|
||||||
|
export { route };
|
||||||
|
|
||||||
|
/** catch-all:SSR feed 白名单之外的一切 URL */
|
||||||
|
function route(pageContext: PageContext) {
|
||||||
|
if (matchFeedSsrPath(pageContext.urlPathname)) return false;
|
||||||
|
return { routeParams: {} };
|
||||||
|
}
|
||||||
4
frontend/pages/spa/+ssr.ts
Normal file
4
frontend/pages/spa/+ssr.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ssr };
|
||||||
|
|
||||||
|
/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
|
||||||
|
const ssr = false;
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user