Compare commits
102 Commits
99ae0ccdaa
...
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
@@ -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「快速开始」如需对外暴露再更新
|
||||||
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Git 与编辑器
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
|
||||||
|
# 运行时数据与本地配置
|
||||||
|
data/
|
||||||
|
app.ini
|
||||||
|
tmp-cookie.txt
|
||||||
|
|
||||||
|
# 编译产物与前端缓存
|
||||||
|
dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
embed_static/static/spa/
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# 文档与截图(不参与镜像构建)
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# 计划文件
|
||||||
|
*.plan.md
|
||||||
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
@@ -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
@@ -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
|
||||||
11
.gitignore
vendored
@@ -1,9 +1,14 @@
|
|||||||
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
# 本地配置(保留 app.ini.example)
|
||||||
|
/app.ini
|
||||||
|
|
||||||
# 前端依赖与构建缓存
|
# 前端依赖与构建缓存
|
||||||
|
/node_modules/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
|
/embed_static/static/spa/
|
||||||
|
|
||||||
# Go 编译产物
|
# Go 编译产物
|
||||||
/dist/
|
/dist/
|
||||||
@@ -15,6 +20,12 @@ tmp-cookie.txt
|
|||||||
# 编辑器 / OS
|
# 编辑器 / OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.trae/documents/
|
||||||
|
.trae/chat/
|
||||||
|
.trae/image/
|
||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
||||||
|
frontend/tmp-lucide-paths.json
|
||||||
|
|||||||
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「快速开始」如需对外暴露再更新
|
||||||
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "de7e4cff",
|
||||||
|
"configHash": "9a7296da",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "8c168d3c",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
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)。
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||||
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像
|
||||||
|
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||||
|
#
|
||||||
|
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||||
|
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||||
|
# docker build --build-arg IMAGE_PREFIX= ...
|
||||||
|
|
||||||
|
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)────────────────────
|
||||||
|
FROM ${IMAGE_PREFIX}node:22-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)────────────────────────
|
||||||
|
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||||
|
ARG VERSION
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-o /out/jiang13 ./cmd/jiang13
|
||||||
|
|
||||||
|
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
|
||||||
|
FROM ${IMAGE_PREFIX}alpine:3.21
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||||
|
&& apk add --no-cache ca-certificates tzdata wget su-exec \
|
||||||
|
&& adduser -D -u 1000 -h /app jiang13
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/jiang13 /app/jiang13
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chown jiang13:jiang13 /app/jiang13 \
|
||||||
|
&& chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||||
|
|
||||||
|
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]
|
||||||
63
Makefile
@@ -4,67 +4,89 @@
|
|||||||
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 build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
cd frontend && npm install && npm run build
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
## 编译当前平台二进制
|
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||||
build: frontend-build
|
build: frontend-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(先打包前端再 embed)
|
||||||
build-windows:
|
build-windows: frontend-build
|
||||||
@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(先打包前端再 embed)
|
||||||
build-linux:
|
build-linux: frontend-build
|
||||||
@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)(先打包前端再 embed)
|
||||||
build-darwin:
|
build-darwin: frontend-build
|
||||||
@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"
|
||||||
|
|
||||||
## 跨平台全量编译
|
## 跨平台全量编译(frontend-build 只跑一次)
|
||||||
build-all: build-windows build-linux build-darwin build
|
build-all: frontend-build
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||||
|
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 的前端)
|
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 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 同时退出)
|
## 前端热更新开发(后端 :3000 + Vite :5173,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 启动
|
||||||
|
compose-up:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
## Docker Compose 停止
|
||||||
|
compose-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "姜十三论坛编译命令:"
|
@echo "姜十三论坛编译命令:"
|
||||||
@echo " make build - 编译当前平台"
|
@echo " make build - 编译当前平台"
|
||||||
@@ -74,3 +96,6 @@ help:
|
|||||||
@echo " make build-all - 编译全部平台"
|
@echo " make build-all - 编译全部平台"
|
||||||
@echo " make run - 仅启动后端(:3000)"
|
@echo " make run - 仅启动后端(: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 停止"
|
||||||
|
|||||||
554
README.md
@@ -1,187 +1,473 @@
|
|||||||
|
<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 兼容对象存储
|
||||||
|
|
||||||
|
### 部署体验
|
||||||
|
|
||||||
|
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||||
|
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||||
|
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||||
|
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||||
|
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 编译
|
### 1. 编译
|
||||||
|
|
||||||
**Windows(推荐,无需 GNU Make):**
|
**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
|
```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
|
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
> Windows 自带的 `make` 通常是 Embarcadero MAKE,**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make(`choco install make`)后再用 `make build`。
|
|
||||||
|
|
||||||
跨平台编译:
|
跨平台编译:
|
||||||
|
|
||||||
```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
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
```bat
|
||||||
|
.\build.bat -Target compose-up
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make compose-up
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 `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):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
docker run -d --name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
|
||||||
|
|
||||||
|
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||||
|
|
||||||
|
**可选环境变量(容器编排):**
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||||
|
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||||
|
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||||
|
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||||
|
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||||
|
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||||
|
|
||||||
|
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||||
|
|
||||||
|
**发布镜像到 Docker Hub(手动):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login
|
||||||
|
.\build.bat -Target docker # Windows
|
||||||
|
# make docker # Linux/macOS
|
||||||
|
docker push hangzhang714128/jiang13-forum:1.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. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||||
|
2. 端口映射 `3000:3000`
|
||||||
|
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||||
|
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||||
|
|
||||||
|
### 3. 直接启动(二进制)
|
||||||
|
|
||||||
|
不想自己编译时,从 [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)
|
||||||
|
|
||||||
|
把文件放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
# Windows
|
||||||
.\dist\jiang13.exe --port 3000 --data ./data
|
.\jiang13-1.1.7-windows-amd64.exe
|
||||||
|
# 或改名为 jiang13.exe 后:
|
||||||
|
.\jiang13.exe
|
||||||
|
|
||||||
# Linux / macOS
|
# Linux
|
||||||
./dist/jiang13 --port 3000 --data ./data
|
chmod +x jiang13-1.1.7-linux-amd64
|
||||||
|
./jiang13-1.1.7-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 首次使用
|
本地刚编译的产物在 `dist/`:
|
||||||
|
|
||||||
1. 浏览器打开 http://localhost:3000/register 注册账号
|
```bash
|
||||||
2. **第一个注册的用户自动成为管理员**,无需额外命令
|
# Windows
|
||||||
3. 登录后可访问 http://localhost:3000/admin/dashboard 进入后台
|
.\dist\jiang13.exe
|
||||||
|
|
||||||
## 启动参数
|
# Linux / macOS
|
||||||
|
./dist/jiang13
|
||||||
|
```
|
||||||
|
|
||||||
|
也可先复制示例配置再改端口 / 数据目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp app.ini.example /opt/jiang13/app.ini
|
||||||
|
# 编辑 app.ini 后:
|
||||||
|
./jiang13
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 |
|
||||||
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 |
|
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||||
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 |
|
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||||
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 |
|
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||||
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端开发
|
||||||
|
|
||||||
|
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build.bat -Target dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||||
|
|
||||||
|
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||||
|
|
||||||
|
**何时需要完整构建:**
|
||||||
|
|
||||||
|
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||||
|
- 更新 README 界面截图 → 见下方「更新截图」
|
||||||
|
|
||||||
|
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||||
|
|
||||||
|
### 更新截图
|
||||||
|
|
||||||
|
默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D playwright
|
||||||
|
npx playwright install chromium
|
||||||
|
node scripts/capture-screenshots.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
| 环境变量 | 说明 | 默认 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` |
|
||||||
|
| `J13_POST_ID` | 详情页帖子 ID | `1` |
|
||||||
|
| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` |
|
||||||
|
| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` |
|
||||||
|
|
||||||
|
本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
jiang13-forum/
|
||||||
|
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||||
|
├── config/ # app.ini 与命令行配置
|
||||||
|
├── app.ini.example # 配置文件示例
|
||||||
|
├── Dockerfile # 多阶段 Docker 构建
|
||||||
|
├── docker-compose.yml # 单容器 Compose 部署
|
||||||
|
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
|
||||||
|
├── .dockerignore
|
||||||
|
├── model/ # GORM 模型与数据库迁移
|
||||||
|
├── service/ # 业务逻辑
|
||||||
|
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||||
|
├── middleware/ # JWT 鉴权等
|
||||||
|
├── router/ # 路由注册
|
||||||
|
├── embed_static/ # go:embed 内嵌的 SPA
|
||||||
|
├── frontend/ # React 源码(Vite 构建)
|
||||||
|
├── 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
@@ -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
@@ -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%
|
||||||
|
|||||||
63
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', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', '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) {
|
||||||
@@ -39,16 +41,30 @@ 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
|
||||||
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
try {
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
||||||
Write-Host "[ok] $outPath" -ForegroundColor Green
|
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||||
|
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) {
|
||||||
@@ -62,6 +78,9 @@ switch ($Target) {
|
|||||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (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.'
|
||||||
}
|
}
|
||||||
@@ -72,18 +91,21 @@ 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
|
||||||
}
|
}
|
||||||
'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 (Vite 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
|
||||||
@@ -103,6 +125,7 @@ switch ($Target) {
|
|||||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
'build-linux' {
|
'build-linux' {
|
||||||
|
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||||
Build-Frontend
|
Build-Frontend
|
||||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
@@ -117,4 +140,20 @@ switch ($Target) {
|
|||||||
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)
|
||||||
}
|
|
||||||
|
|
||||||
// 优雅关机
|
|
||||||
go func() {
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-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)
|
|
||||||
}
|
}
|
||||||
}()
|
return
|
||||||
|
}
|
||||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
|
||||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
// 交互终端或由服务管理器拉起时均走 Run:
|
||||||
log.Printf("数据目录: %s", cfg.DataDir)
|
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
|
||||||
|
if err := svc.Run(); err != nil {
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
log.Fatalf("运行失败: %v", err)
|
||||||
log.Fatalf("HTTP 服务异常: %v", err)
|
|
||||||
}
|
}
|
||||||
log.Println("姜十三论坛已安全退出")
|
|
||||||
}
|
}
|
||||||
|
|||||||
166
cmd/jiang13/program.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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)
|
||||||
|
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 姜十三论坛 — Docker Compose 单服务部署
|
||||||
|
# 启动:docker compose up -d --build
|
||||||
|
# 或:make compose-up / build.bat -Target compose-up
|
||||||
|
|
||||||
|
services:
|
||||||
|
jiang13:
|
||||||
|
image: hangzhang714128/jiang13-forum:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
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
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 15s
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jiang13-data:
|
||||||
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||||
|
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
|
||||||
|
|
||||||
|
DATA_DIR="/data"
|
||||||
|
|
||||||
|
if [ "$(id -u)" = '0' ]; then
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
chown -R jiang13:jiang13 "$DATA_DIR"
|
||||||
|
exec su-exec jiang13 /app/jiang13 "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /app/jiang13 "$@"
|
||||||
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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/screenshots/home-light.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/screenshots/mobile-home.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/screenshots/post-detail.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/screenshots/post-rich.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
38
docs/site-posts/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 站务公告 · 发帖素材
|
||||||
|
|
||||||
|
## 板块设置
|
||||||
|
|
||||||
|
| 项 | 建议值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 名称 | 站务公告 |
|
||||||
|
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
|
||||||
|
| 图标 | `megaphone` |
|
||||||
|
| 色标 | 建议第 0 或 1 槽 |
|
||||||
|
| 排序 | `0` |
|
||||||
|
|
||||||
|
## 主文(必发)
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
|
||||||
|
|
||||||
|
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||||
|
|
||||||
|
## 自定义单页(推荐)
|
||||||
|
|
||||||
|
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
|
||||||
|
|
||||||
|
| 文件 | 标题 | slug | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
|
||||||
|
|
||||||
|
访问路径:`/page/changelog`。
|
||||||
|
|
||||||
|
## 可选短文
|
||||||
|
|
||||||
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
|
|
||||||
|
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
|
||||||
|
- [feedback.md](./feedback.md) — 反馈与建议
|
||||||
|
|
||||||
|
复制正文时:从各文件第一个 `---` **之后**开始粘贴。
|
||||||
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。
|
||||||
|
|
||||||
|
有问题可联系管理员。祝你在这里聊得愉快。
|
||||||
160
docs/site-posts/changelog.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
> 建议标题:更新日志
|
||||||
|
> 建议 slug:`changelog`
|
||||||
|
> 建议路径:`/page/changelog`
|
||||||
|
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
|
||||||
|
|
||||||
|
**当前版本:1.1.7**(2026-09-02)
|
||||||
|
|
||||||
|
## 去哪看新版本
|
||||||
|
|
||||||
|
| 渠道 | 看什么 | 地址 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
|
||||||
|
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
|
||||||
|
| Windows / Linux | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
|
||||||
|
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
|
||||||
|
|
||||||
|
**1.1.7 直接下载:**
|
||||||
|
|
||||||
|
- Windows x64:[jiang13-1.1.7-windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe)
|
||||||
|
- Linux x64:[jiang13-1.1.7-linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
- 校验文件:[SHA256SUMS.txt](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/SHA256SUMS.txt)
|
||||||
|
|
||||||
|
拉取指定版本镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
# 或始终跟随最新
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
|
||||||
|
|
||||||
|
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
|
||||||
|
|
||||||
|
## 升级注意
|
||||||
|
|
||||||
|
- 一般只需换镜像或换 exe,数据目录向后兼容。
|
||||||
|
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
|
||||||
|
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.1.7 — 2026-09-02
|
||||||
|
|
||||||
|
私信与开源展柜体验升级,待审通知的审核态更及时。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
|
||||||
|
- 主页「发私信」直达消息页对应会话
|
||||||
|
- 开源展柜改为目录式 UI,空列表不再粘滞
|
||||||
|
- 消息页体验重构;待审通知打开后实时回填审核态
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
|
||||||
|
|
||||||
|
## 1.1.6 — 2026-09-01
|
||||||
|
|
||||||
|
编辑器插入体验与首页默认 Feed 排序更合理。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
|
||||||
|
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
|
||||||
|
- 链接是否新标签打开交由全站设置,对话框不再单独开关
|
||||||
|
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
|
||||||
|
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
|
||||||
|
|
||||||
|
## 1.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 一致,减少闪动
|
||||||
|
|
||||||
|
## 1.1.4 — 2026-08-31
|
||||||
|
|
||||||
|
网站监控的地理信息与口径更准确。
|
||||||
|
|
||||||
|
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
|
||||||
|
- 对齐监控概览「双通道」口径文案与诊断抽样说明
|
||||||
|
|
||||||
|
## 1.1.3 — 2026-08-31
|
||||||
|
|
||||||
|
管理后台可看站点访问情况。
|
||||||
|
|
||||||
|
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
|
||||||
|
|
||||||
|
## 1.1.2 — 2026-08-31
|
||||||
|
|
||||||
|
可选接入官方社区展柜。
|
||||||
|
|
||||||
|
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
|
||||||
|
|
||||||
|
## 1.1.1 — 2026-08-31
|
||||||
|
|
||||||
|
- 评论管理操作收进「更多」菜单,管理员界面更干净
|
||||||
|
|
||||||
|
## 1.1.0 — 2026-08-30
|
||||||
|
|
||||||
|
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
- 友链申请、独立友链页;页脚左右区域可分别开关
|
||||||
|
- 自定义单页(后台「单页管理」,如本页)
|
||||||
|
- 投票帖、悬赏帖、抽奖帖
|
||||||
|
- 侧栏签到;右侧栏「最新注册」
|
||||||
|
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
|
||||||
|
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
|
||||||
|
|
||||||
|
**优化**
|
||||||
|
|
||||||
|
- 首页帖子列表密度与标题样式
|
||||||
|
- 开源码桶展示加强,去掉 Feed 顶栏统计
|
||||||
|
- 单页编辑、列表留白与友链申请体验
|
||||||
|
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 手机端搜索入口与底部 Sheet(抽屉面板)
|
||||||
|
- 占位头像对比度;无头像用户不再误用游客灰底
|
||||||
|
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
|
||||||
|
- 单页正文排版与帖子详情对齐
|
||||||
|
|
||||||
|
## 1.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
@@ -0,0 +1,30 @@
|
|||||||
|
# 本站发帖约定
|
||||||
|
|
||||||
|
> 建议标题:本站发帖约定
|
||||||
|
> 建议标签:`站务` `公约`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站是小圈子论坛,约定尽量少、好遵守。
|
||||||
|
|
||||||
|
## 请这样做
|
||||||
|
|
||||||
|
- 选对板块再发;不确定就发到综合讨论类板块
|
||||||
|
- 标题写清楚在说什么,方便后人搜索
|
||||||
|
- 讨论对事不对人;引用、回复尽量指向具体楼层
|
||||||
|
- 分享链接时简单说明来源与用途
|
||||||
|
|
||||||
|
## 请避免
|
||||||
|
|
||||||
|
- 在「站务公告」灌水、闲聊(请到其他板块)
|
||||||
|
- 无意义刷屏、重复灌帖
|
||||||
|
- 人身攻击、骚扰、泄露他人隐私
|
||||||
|
- 明显的广告、引流(除非板块明确允许)
|
||||||
|
|
||||||
|
## 管理说明
|
||||||
|
|
||||||
|
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
|
||||||
|
有争议可私信管理员说明情况。
|
||||||
|
|
||||||
|
一句话:**把讨论留给值得留下的内容。**
|
||||||
26
docs/site-posts/feedback.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 反馈与建议
|
||||||
|
|
||||||
|
> 建议标题:反馈与建议
|
||||||
|
> 建议标签:`站务` `反馈`
|
||||||
|
> 建议操作:普通发布(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
|
||||||
|
|
||||||
|
## 站内怎么说
|
||||||
|
|
||||||
|
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
|
||||||
|
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
|
||||||
|
- **站务问题**(注册、权限、内容处理):私信管理员
|
||||||
|
|
||||||
|
## 开发仓库(可选)
|
||||||
|
|
||||||
|
若你也参与程序本身:
|
||||||
|
|
||||||
|
- 查路线图、提 Issue / PR:见项目仓库
|
||||||
|
- 许可证:MIT
|
||||||
|
|
||||||
|
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
|
||||||
|
|
||||||
|
感谢一起把小圈子聊好。
|
||||||
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) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |
|
||||||
@@ -2,9 +2,9 @@ package embed_static
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -13,61 +13,106 @@ import (
|
|||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
//go:embed templates/*
|
var (
|
||||||
var templatesFS embed.FS
|
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||||
|
spaBrandTitleFn func() string
|
||||||
|
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||||
|
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
||||||
|
)
|
||||||
|
|
||||||
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
|
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||||
|
func SetSPADocumentTitle(fn func() string) {
|
||||||
|
spaBrandTitleFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
||||||
|
func SetSPABrandingJSON(fn func() []byte) {
|
||||||
|
spaBrandJSONFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
||||||
|
func SetSPAFaviconURL(fn func() string) {
|
||||||
|
spaBrandFaviconFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||||
func SetupEmbed(r *gin.Engine) error {
|
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 {
|
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)))))
|
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||||
|
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||||
|
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
||||||
|
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
||||||
|
c.Writer = w
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台管理遗留静态资源
|
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
|
||||||
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
|
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
|
||||||
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
|
r.GET("/stickers/*filepath", func(c *gin.Context) {
|
||||||
|
// stickers 不含哈希指纹,设置适中的缓存
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeSPA 返回 React SPA 入口
|
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
|
||||||
func ServeSPA(c *gin.Context) {
|
type cacheOnOKWriter struct {
|
||||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
gin.ResponseWriter
|
||||||
if err != nil {
|
cacheControl string
|
||||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
wroteHeader bool
|
||||||
return
|
}
|
||||||
|
|
||||||
|
func (w *cacheOnOKWriter) WriteHeader(code int) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.wroteHeader = true
|
||||||
|
if code == http.StatusOK {
|
||||||
|
w.Header().Set("Cache-Control", w.cacheControl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||||
|
func ServeSPA(c *gin.Context) {
|
||||||
|
ServeSPAWithMeta(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
|
||||||
|
func ServeSPANoIndex(c *gin.Context) {
|
||||||
|
title := ""
|
||||||
|
if spaBrandTitleFn != nil {
|
||||||
|
title = strings.TrimSpace(spaBrandTitleFn())
|
||||||
|
}
|
||||||
|
ServeSPAWithMeta(c, &SPAPageMeta{
|
||||||
|
Title: title,
|
||||||
|
Robots: "noindex,nofollow",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSPARoute 判断是否应由 SPA 处理
|
// IsSPARoute 判断是否应由 SPA 处理
|
||||||
func IsSPARoute(path string) bool {
|
func IsSPARoute(path string) bool {
|
||||||
|
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if strings.HasPrefix(path, "/api") ||
|
if strings.HasPrefix(path, "/api") ||
|
||||||
strings.HasPrefix(path, "/admin") ||
|
strings.HasPrefix(path, "/admin") ||
|
||||||
strings.HasPrefix(path, "/uploads") ||
|
strings.HasPrefix(path, "/uploads") ||
|
||||||
strings.HasPrefix(path, "/legacy") ||
|
strings.HasPrefix(path, "/media") ||
|
||||||
strings.HasPrefix(path, "/assets") {
|
strings.HasPrefix(path, "/assets") ||
|
||||||
|
strings.HasPrefix(path, "/stickers") ||
|
||||||
|
strings.HasPrefix(path, "/oauth") ||
|
||||||
|
strings.HasPrefix(path, "/.well-known") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
202
embed_static/spa_meta.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package embed_static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
|
||||||
|
|
||||||
|
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
|
||||||
|
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
|
||||||
|
type SPAPageMeta struct {
|
||||||
|
Title string // 完整 <title>
|
||||||
|
Description string
|
||||||
|
Keywords string // meta keywords
|
||||||
|
Canonical string
|
||||||
|
OGType string // 默认 website
|
||||||
|
OGImage string
|
||||||
|
SiteName string // og:site_name
|
||||||
|
Locale string // og:locale,默认 zh_CN
|
||||||
|
Robots string // 如 noindex,nofollow
|
||||||
|
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||||
|
Status int // HTTP 状态码,0 视为 200
|
||||||
|
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
||||||
|
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||||
|
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||||
|
status := http.StatusOK
|
||||||
|
if meta != nil && meta.Status != 0 {
|
||||||
|
status = meta.Status
|
||||||
|
}
|
||||||
|
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = applySPAPageMeta(data, meta)
|
||||||
|
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Header("Pragma", "no-cache")
|
||||||
|
c.Data(status, "text/html; charset=utf-8", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||||
|
if meta == nil {
|
||||||
|
meta = &SPAPageMeta{}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(meta.Title)
|
||||||
|
if title == "" && spaBrandTitleFn != nil {
|
||||||
|
title = strings.TrimSpace(spaBrandTitleFn())
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
escaped := html.EscapeString(title)
|
||||||
|
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
||||||
|
var seo strings.Builder
|
||||||
|
writeMeta(&seo, "description", meta.Description)
|
||||||
|
writeMeta(&seo, "keywords", meta.Keywords)
|
||||||
|
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||||
|
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||||
|
}
|
||||||
|
if favicon := spaFaviconHref(); favicon != "" {
|
||||||
|
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||||
|
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||||
|
}
|
||||||
|
robots := strings.TrimSpace(meta.Robots)
|
||||||
|
if robots != "" {
|
||||||
|
writeMeta(&seo, "robots", robots)
|
||||||
|
}
|
||||||
|
|
||||||
|
ogType := strings.TrimSpace(meta.OGType)
|
||||||
|
if ogType == "" {
|
||||||
|
ogType = "website"
|
||||||
|
}
|
||||||
|
locale := strings.TrimSpace(meta.Locale)
|
||||||
|
if locale == "" {
|
||||||
|
locale = "zh_CN"
|
||||||
|
}
|
||||||
|
writeProp(&seo, "og:type", ogType)
|
||||||
|
writeProp(&seo, "og:site_name", meta.SiteName)
|
||||||
|
writeProp(&seo, "og:locale", locale)
|
||||||
|
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
||||||
|
writeProp(&seo, "og:description", meta.Description)
|
||||||
|
writeProp(&seo, "og:url", meta.Canonical)
|
||||||
|
writeProp(&seo, "og:image", meta.OGImage)
|
||||||
|
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
||||||
|
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||||
|
writeMetaName(&seo, "twitter:description", meta.Description)
|
||||||
|
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
||||||
|
|
||||||
|
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||||
|
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
||||||
|
seo.WriteString(`<script type="application/ld+json">`)
|
||||||
|
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
||||||
|
seo.WriteString(`</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seo.Len() > 0 {
|
||||||
|
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
||||||
|
var boot strings.Builder
|
||||||
|
if s := spaBrandingBootScript(); s != "" {
|
||||||
|
boot.WriteString(s)
|
||||||
|
}
|
||||||
|
if s := spaHomeBootScript(meta.BootJSON); s != "" {
|
||||||
|
boot.WriteString(s)
|
||||||
|
}
|
||||||
|
if boot.Len() > 0 {
|
||||||
|
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if root := strings.TrimSpace(meta.RootHTML); root != "" {
|
||||||
|
data = injectSPARootHTML(data, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
|
||||||
|
func spaHomeBootScript(raw []byte) string {
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || !json.Valid(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||||
|
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
|
||||||
|
func injectSPARootHTML(data []byte, rootHTML string) []byte {
|
||||||
|
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||||
|
func spaBrandingBootScript() string {
|
||||||
|
if spaBrandJSONFn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw := bytes.TrimSpace(spaBrandJSONFn())
|
||||||
|
if len(raw) == 0 || !json.Valid(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||||
|
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMeta(b *strings.Builder, name, content string) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMetaName(b *strings.Builder, name, content string) {
|
||||||
|
writeMeta(b, name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProp(b *strings.Builder, prop, content string) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twitterCard(ogImage string) string {
|
||||||
|
if strings.TrimSpace(ogImage) != "" {
|
||||||
|
return "summary_large_image"
|
||||||
|
}
|
||||||
|
return "summary"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if s := strings.TrimSpace(v); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
||||||
|
func spaFaviconHref() string {
|
||||||
|
if spaBrandFaviconFn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(spaBrandFaviconFn())
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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}}
|
|
||||||
@@ -2,24 +2,10 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>姜十三论坛 Jiang13 Forum</title>
|
<meta name="description" content="拾三一隅,自在交流" />
|
||||||
<style>
|
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 style -->
|
||||||
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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||||
@@ -27,6 +13,33 @@
|
|||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
/* 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
|
||||||
|
(function () {
|
||||||
|
function inZoomAllowArea(target) {
|
||||||
|
return !!(
|
||||||
|
target &&
|
||||||
|
target.closest &&
|
||||||
|
target.closest('.avatar-crop-stage, .image-lightbox')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function blockGesture(e) {
|
||||||
|
if (inZoomAllowArea(e.target)) return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
}
|
||||||
|
function blockMultiTouch(e) {
|
||||||
|
if (e.touches && e.touches.length > 1) {
|
||||||
|
if (inZoomAllowArea(e.target)) return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('gesturestart', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('gesturechange', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('gestureend', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
|
||||||
|
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
839
frontend/package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"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,29 +19,47 @@
|
|||||||
"@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",
|
||||||
|
"@svg-maps/china": "^2.0.0",
|
||||||
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
|
"@tiptap/core": "^3.26.1",
|
||||||
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.26.1",
|
||||||
|
"@tiptap/extension-table": "^3.26.1",
|
||||||
|
"@tiptap/extension-underline": "^3.26.1",
|
||||||
|
"@tiptap/pm": "^3.26.1",
|
||||||
|
"@tiptap/react": "^3.26.1",
|
||||||
|
"@tiptap/starter-kit": "^3.26.1",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"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-dom": "^6.28.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.4.3"
|
"turndown": "^7.2.4",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
|
|||||||
BIN
frontend/public/stickers/bilibili/bl_01.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_02.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/stickers/bilibili/bl_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_04.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_05.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_06.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_07.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_08.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_09.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |