Compare commits

...

2 Commits

Author SHA1 Message Date
freefire
99ae0ccdaa 合并 Gitea 远程初始提交,保留本地项目文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:09:20 +08:00
freefire
e1c1708715 初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:08:52 +08:00
140 changed files with 16105 additions and 38 deletions

41
.gitignore vendored
View File

@@ -1,27 +1,20 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
# 运行时数据数据库、JWT 密钥、日志、头像)
/data/
# 前端依赖与构建缓存
/frontend/node_modules/
/frontend/dist/
# Go 编译产物
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# 临时文件
tmp-cookie.txt
# 编辑器 / OS
.idea/
.vscode/
*.swp
Thumbs.db
.DS_Store

27
LICENSE
View File

@@ -2,17 +2,20 @@ MIT License
Copyright (c) 2026 freefire
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

76
Makefile Normal file
View File

@@ -0,0 +1,76 @@
# 姜十三论坛 Jiang13 Forum - Makefile
# Go 1.26 单二进制编译,与 Gitea 打包方式一致
APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist
VERSION := 1.0.0
LDFLAGS := -s -w -X main.version=$(VERSION)
GO := go
GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
all: build
frontend-build:
cd frontend && npm install && npm run build
## 编译当前平台二进制
build: frontend-build
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64
build-windows:
@mkdir -p $(BUILD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64
build-linux:
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)
build-darwin:
@mkdir -p $(BUILD_DIR)
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"
## 跨平台全量编译
build-all: build-windows build-linux build-darwin build
@echo "✓ 全平台编译完成"
## 整理依赖
tidy:
$(GO) mod tidy
## 本地运行(仅后端,使用已 embed 的前端)
run:
$(GO) run $(MAIN_PKG) --port 3000 --data ./data
## 前端热更新开发(后端 :3000 + Vite :5173Ctrl+C 同时退出)
dev:
@echo "前端热更新: http://localhost:5173"
@echo "后端 API : http://localhost:3000"
@trap 'kill 0' INT; \
$(GO) run $(MAIN_PKG) --port 3000 --data ./data & \
cd frontend && (test -d node_modules || npm install) && npm run dev
## 清理编译产物
clean:
rm -rf $(BUILD_DIR)
help:
@echo "姜十三论坛编译命令:"
@echo " make build - 编译当前平台"
@echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台"
@echo " make run - 仅启动后端(:3000"
@echo " make dev - 前端热更新开发(:5173 + :3000"

188
README.md
View File

@@ -1,3 +1,187 @@
# jiang13-forum
# 姜十三论坛 Jiang13 Forum
轻量自用论坛Go 二进制 + React SPA 内嵌 + SQLite面向小圈子内部交流。
轻量自用论坛系统,面向小圈子内部交流。与 Gitea 相同,编译为**单个独立 Go 二进制**,前端静态资源 embed 内嵌,内置 SQLite开箱即用。
## 项目目录结构
```
js13-forum/
├── cmd/jiang13/main.go # 程序入口
├── config/config.go # 命令行参数与配置
├── model/
│ ├── models.go # GORM 模型定义
│ └── db.go # 数据库初始化与自动迁移
├── service/ # 业务逻辑层
│ ├── auth.go # 注册/登录/JWT
│ ├── user.go # 用户资料/头像
│ ├── board.go # 板块 CRUD
│ ├── post.go # 帖子/点赞/收藏
│ ├── comment.go # 楼层评论
│ ├── backup.go # SQLite 备份
│ ├── ratelimit.go # 访问限流
│ └── common.go # 敏感词过滤等
├── handler/
│ ├── handlers.go # 前台页面与 API
│ └── admin.go # 后台管理
├── middleware/auth.go # JWT 鉴权与限流中间件
├── router/router.go # 路由注册
├── embed_static/
│ ├── embed.go # go:embed 声明
│ ├── static/css/style.css # 内嵌 CSS
│ ├── static/js/app.js # 内嵌 JS
│ └── templates/ # 内嵌 HTML 模板
├── go.mod
├── Makefile
└── README.md
```
## 快速开始
### 1. 编译
**Windows推荐无需 GNU Make**
```powershell
# PowerShell
.\build.ps1
# 或双击 / cmd
build.bat
```
**Linux / macOS需 GNU Make**
```bash
make build
```
**手动分步(全平台通用):**
```bash
cd frontend && npm install && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
```
> Windows 自带的 `make` 通常是 Embarcadero MAKE**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make`choco install make`)后再用 `make build`。
跨平台编译:
```powershell
.\build.ps1 -Target build-windows
.\build.ps1 -Target build-linux
.\build.ps1 -Target build-all
```
### 2. 启动
```bash
# Windows
.\dist\jiang13.exe --port 3000 --data ./data
# Linux / macOS
./dist/jiang13 --port 3000 --data ./data
```
### 3. 首次使用
1. 浏览器打开 http://localhost:3000/register 注册账号
2. **第一个注册的用户自动成为管理员**,无需额外命令
3. 登录后可访问 http://localhost:3000/admin/dashboard 进入后台
## 启动参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--port` | 3000 | HTTP 监听端口 |
| `--data` | ./data | 数据目录SQLite、上传、日志 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则自动生成并持久化到 data/.jwt_secret |
## 数据目录说明
```
data/
├── jiang13.db # SQLite 主数据库
├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置(可编辑)
├── .jwt_secret # JWT 密钥(自动生成)
├── uploads/avatars/ # 用户头像(运行时写入,非 embed
└── jiang13_backup_*.db # 后台导出的备份文件
```
## 修改前端并重新打包Gitea 同款流程)
1. 编辑 `embed_static/templates/` 下的 HTML 模板
2. 编辑 `embed_static/static/css/``static/js/` 下的静态资源
3. 重新编译:`make build`
4. 替换旧二进制,重启服务
前端资源通过 `//go:embed` 编译进二进制,**运行时不需要单独的前端文件夹**。只有用户上传的头像保存在 `--data` 目录。
```go
// embed_static/embed.go
//go:embed static/*
var staticFS embed.FS
//go:embed templates/*
var templatesFS embed.FS
```
## 功能概览
- 用户注册/登录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 风格)
| 特性 | 实现 |
|------|------|
| 三栏布局 | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 |
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 |
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 |
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
## 许可证
MIT — 自用随意,欢迎修改。

4
build.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
REM 姜十三论坛 Windows 快捷构建(双击或命令行均可)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL%

120
build.ps1 Normal file
View File

@@ -0,0 +1,120 @@
# Jiang13 Forum - Windows build script (replaces GNU Make)
# Usage: .\build.ps1
# .\build.ps1 -Target build-windows
param(
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
[string]$Target = 'build'
)
$ErrorActionPreference = 'Stop'
$AppName = 'jiang13'
$MainPkg = './cmd/jiang13'
$BuildDir = 'dist'
$Version = '1.0.0'
$Ldlags = "-s -w -X main.version=$Version"
function Ensure-Dir($path) {
if (-not (Test-Path $path)) {
New-Item -ItemType Directory -Path $path | Out-Null
}
}
function Build-Frontend {
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
Push-Location frontend
try {
if (-not (Test-Path node_modules)) {
npm install
}
npm run build
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
} finally {
Pop-Location
}
}
function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
Ensure-Dir $BuildDir
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
$OutFile = "$OutFile.exe"
}
$outPath = Join-Path $BuildDir $OutFile
Write-Host "[go] build -> $outPath" -ForegroundColor Cyan
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
Write-Host "[ok] $outPath" -ForegroundColor Green
}
switch ($Target) {
'help' {
Write-Host '.\build.ps1 build current platform'
Write-Host '.\build.ps1 -Target frontend frontend only'
Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean'
Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
}
'frontend' { Build-Frontend }
'tidy' { go mod tidy }
'clean' {
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
Write-Host '[ok] cleaned dist' -ForegroundColor Green
}
'run' {
go run $MainPkg --port 3000 --data ./data
}
'dev' {
$root = (Get-Location).Path
Write-Host ''
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data"
) | Out-Null
Start-Sleep -Seconds 2
Push-Location frontend
try {
if (-not (Test-Path node_modules)) { npm install }
npm run dev
} finally {
Pop-Location
}
}
'build' {
Build-Frontend
Build-Go -OutFile $AppName
}
'build-windows' {
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
'build-darwin' {
Build-Frontend
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
}
'build-all' {
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
Write-Host '[ok] all platforms done' -ForegroundColor Green
}
}

76
cmd/jiang13/main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jiang13/forum/config"
"github.com/jiang13/forum/model"
"github.com/jiang13/forum/router"
)
func main() {
cfg, err := config.Parse()
if err != nil {
log.Fatalf("配置解析失败: %v", err)
}
// 日志同时输出到控制台和文件
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
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)
}
// 设置路由
engine, err := router.Setup(cfg)
if err != nil {
log.Fatalf("路由初始化失败: %v", err)
}
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: engine,
}
// 优雅关机
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)
}
}()
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
log.Printf("数据目录: %s", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP 服务异常: %v", err)
}
log.Println("姜十三论坛已安全退出")
}

81
config/config.go Normal file
View File

@@ -0,0 +1,81 @@
package config
import (
"flag"
"fmt"
"os"
"path/filepath"
)
// Config 应用全局配置,通过命令行参数注入
type Config struct {
// 监听端口
Port int
// 数据目录SQLite 数据库、上传头像、日志文件均存放于此
DataDir string
// JWT 签名密钥
JWTSecret string
// 日志文件路径(相对 DataDir
LogFile string
}
// Parse 解析命令行参数并初始化目录
func Parse() (*Config, error) {
port := flag.Int("port", 3000, "HTTP 监听端口")
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志)")
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化)")
flag.Parse()
// 确保数据目录存在
if err := os.MkdirAll(*dataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
}
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
}
cfg := &Config{
Port: *port,
DataDir: *dataDir,
JWTSecret: *jwtSecret,
LogFile: filepath.Join(*dataDir, "jiang13.log"),
}
// 处理 JWT 密钥持久化
secretFile := filepath.Join(*dataDir, ".jwt_secret")
if cfg.JWTSecret == "" {
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data)
} else {
cfg.JWTSecret = generateRandomSecret(32)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
}
}
return cfg, nil
}
// DBPath 返回 SQLite 数据库文件路径
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db")
}
// UploadDir 返回头像上传目录
func (c *Config) UploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars")
}
// FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
func generateRandomSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}

73
embed_static/embed.go Normal file
View File

@@ -0,0 +1,73 @@
package embed_static
import (
"embed"
"html/template"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
//go:embed templates/*
var templatesFS embed.FS
// SetupEmbed 配置内嵌资源SPA 前端 + 后台 HTML 模板
func SetupEmbed(r *gin.Engine) error {
tmpl, err := LoadTemplates()
if err != nil {
return err
}
r.SetHTMLTemplate(tmpl)
// React SPA 构建产物Vite
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
}
// 后台管理遗留静态资源
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
}
return nil
}
// ServeSPA 返回 React SPA 入口
func ServeSPA(c *gin.Context) {
data, err := staticFS.ReadFile("static/spa/index.html")
if err != nil {
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
// IsSPARoute 判断是否应由 SPA 处理
func IsSPARoute(path string) bool {
if strings.HasPrefix(path, "/api") ||
strings.HasPrefix(path, "/admin") ||
strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/legacy") ||
strings.HasPrefix(path, "/assets") {
return false
}
return true
}
func LoadTemplates() (*template.Template, error) {
sub, err := fs.Sub(templatesFS, "templates")
if err != nil {
return nil, err
}
tmpl := template.New("").Funcs(template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
})
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
}

View File

@@ -0,0 +1,80 @@
/* 姜十三论坛全局样式 */
: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; }
}

View File

@@ -0,0 +1,28 @@
// 姜十三论坛前端通用脚本
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();
}

View File

@@ -0,0 +1,499 @@
/* 姜十三论坛 - 前台 + 管理后台样式 */
: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%; }

View File

@@ -0,0 +1,79 @@
// 姜十三论坛 - 管理后台通用脚本
async function adminFetch(url, opts) {
opts = opts || {};
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
var data;
try {
data = await res.json();
} catch (e) {
throw new Error('服务器响应异常,请重新登录后再试');
}
if (!res.ok) {
throw new Error(data.error || '请求失败');
}
return data;
}
function showToast(msg, type) {
var el = document.getElementById('adminToast');
if (!el) {
alert(msg);
return;
}
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
el.querySelector('.toast-body').textContent = msg;
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
}
function adminConfirm(msg, fn) {
if (confirm(msg)) fn();
}
function setBtnLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) {
btn.dataset.originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
} else if (btn.dataset.originalHtml) {
btn.innerHTML = btn.dataset.originalHtml;
}
}
function adminLogout() {
adminFetch('/admin/api/logout', { method: 'POST' })
.then(function () { location.href = '/admin/login'; })
.catch(function (e) { showToast(e.message, 'danger'); });
}
function postForm(url, method, fields) {
var fd = new FormData();
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
return adminFetch(url, { method: method, body: fd });
}
function reloadSoon() {
setTimeout(function () { location.reload(); }, 600);
}
// 兼容旧模板
function apiPost(url, data, isForm) {
var opts = { method: 'POST', credentials: 'same-origin' };
if (isForm) {
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
if (!(data instanceof HTMLFormElement)) {
var fd = new FormData();
for (var k in data) fd.append(k, data[k]);
opts.body = fd;
}
} else {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(data);
}
return fetch(url, opts).then(function (r) { return r.json(); });
}
function confirmAction(msg, callback) {
adminConfirm(msg, callback);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -0,0 +1 @@
function a(n,t){return n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,23 @@
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`${s} 字的`:"一段";return`
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${n}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,38 @@
<!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>

View File

@@ -0,0 +1,95 @@
{{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}}

View File

@@ -0,0 +1,58 @@
{{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}}

View File

@@ -0,0 +1,76 @@
{{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}}

View File

@@ -0,0 +1,88 @@
{{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}}

View File

@@ -0,0 +1,49 @@
{{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}}

View File

@@ -0,0 +1,67 @@
{{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}}

View File

@@ -0,0 +1,56 @@
{{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}}

View File

@@ -0,0 +1,62 @@
{{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}}

View File

@@ -0,0 +1,16 @@
{{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}}

View File

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

View File

@@ -0,0 +1,10 @@
{{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}}

View File

@@ -0,0 +1,34 @@
{{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}}

View File

@@ -0,0 +1,56 @@
{{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">&copy; 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}}

View File

@@ -0,0 +1,36 @@
{{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}}

View File

@@ -0,0 +1,72 @@
{{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}}

View File

@@ -0,0 +1,31 @@
{{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}}

View File

@@ -0,0 +1,37 @@
{{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">可使用 &lt;b&gt;&lt;i&gt;&lt;a&gt;&lt;img&gt; 等 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}}

View File

@@ -0,0 +1,34 @@
{{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}}

View File

@@ -0,0 +1,40 @@
{{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}}

View File

@@ -0,0 +1,8 @@
{{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}}

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

35
frontend/index.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3685
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "jiang13-forum-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View File

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

45
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
import MainLayout from './layouts/MainLayout';
import ErrorBoundary from './components/ErrorBoundary';
import PageLoader from './components/PageLoader';
import { Toaster } from './components/ui/sonner';
const HomePage = lazy(() => import('./pages/HomePage'));
const PostDetailPage = lazy(() => import('./pages/PostDetailPage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/boards" element={<BoardsManagePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</Routes>
</BrowserRouter>
<Toaster />
</ErrorBoundary>
</AuthProvider>
</ThemeProvider>
);
}

112
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types';
const BASE = '';
async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(BASE + url, {
credentials: 'same-origin',
...opts,
headers: {
...(opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...opts.headers,
},
});
let data: Record<string, unknown>;
try {
data = await res.json();
} catch {
throw new Error('服务器响应异常');
}
if (!res.ok) throw new Error((data.error as string) || '请求失败');
return data as T;
}
export const api = {
me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'),
boards: () => request<{ boards: Board[] }>('/api/boards'),
posts: (params: Record<string, string | number>) => {
const q = new URLSearchParams(params as Record<string, string>).toString();
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
},
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`),
comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
},
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
online: () => request<OnlineStats>('/api/online'),
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
createBoard: (body: { name: string; description: string; sort_order: number }) =>
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
updateNickname: (nickname: string) => {
const fd = new FormData();
fd.append('nickname', nickname);
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} });
},
updatePassword: (oldPassword: string, newPassword: string) => {
const fd = new FormData();
fd.append('old_password', oldPassword);
fd.append('new_password', newPassword);
return request('/api/profile/password', { method: 'POST', body: fd, headers: {} });
},
uploadAvatar: (file: File) => {
const fd = new FormData();
fd.append('avatar', file);
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
},
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
const fd = new FormData();
fd.append('board_id', data.board_id);
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
return request<{ post_id: number }>('/api/posts', { method: 'POST', body: fd, headers: {} });
},
updatePost: (id: number, data: { title: string; content: string; tags?: string }) => {
const fd = new FormData();
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
},
login: (username: string, password: string) => {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
return request('/api/login', { method: 'POST', body: fd, headers: {} });
},
register: (username: string, password: string, nickname: string) => {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
fd.append('nickname', nickname);
return request('/api/register', { method: 'POST', body: fd, headers: {} });
},
logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
addComment: (postId: number, data: {
content: string;
replyTo?: number;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate?: boolean;
}) => {
const fd = new FormData();
fd.append('content', data.content);
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
if (data.guestNick) fd.append('guest_nick', data.guestNick);
if (data.guestEmail) fd.append('guest_email', data.guestEmail);
if (data.guestUrl) fd.append('guest_url', data.guestUrl);
if (data.isPrivate) fd.append('is_private', '1');
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
},
ping: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/ping', { method: 'POST' }),
};

74
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,74 @@
export interface User {
id: number;
username: string;
nickname: string;
avatar: string;
role: 'user' | 'admin';
}
export interface Board {
id: number;
name: string;
description: string;
sort_order: number;
post_count?: number;
}
export interface ForumStats {
users: number;
posts: number;
boards: number;
}
export interface PostItem {
id: number;
board_id: number;
user_id: number;
title: string;
content?: string;
tags: string;
pinned: boolean;
like_count: number;
view_count: number;
comment_count: number;
created_at: string;
board?: Board;
user?: User;
}
export interface Comment {
id: number;
post_id: number;
user_id: number;
floor: number;
content: string;
reply_to?: number;
guest_nick?: string;
guest_email?: string;
guest_url?: string;
is_private?: boolean;
content_hidden?: boolean;
created_at: string;
user?: User;
reply_target?: Comment;
}
export interface Notification {
id: number;
title: string;
type: string;
created_at: string;
}
export interface OnlineUser {
id: number;
nickname: string;
avatar: string;
}
export interface OnlineStats {
count: number;
members: number;
guests: number;
users: OnlineUser[];
}

View File

@@ -0,0 +1,281 @@
import {
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
} from 'react';
import {
Bold, Italic, Strikethrough, Link, Code, Quote,
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
} from 'lucide-react';
import { markdownToHtml, countWords } from '../utils/markdown';
import { renderPostContentHtml } from '../utils/postContent';
export interface ArticleEditorHandle {
getHTML: () => string;
getMarkdown: () => string;
isEmpty: () => boolean;
focus: () => void;
}
interface Props {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
type ViewMode = 'edit' | 'preview' | 'split';
interface ToolBtn {
icon: ReactNode;
title: string;
action: () => void;
}
/** 去掉行首已有的 Markdown 块级前缀 */
function stripLinePrefix(line: string): string {
return line
.replace(/^\r/, '')
.replace(/^#{1,6}\s*/, '')
.replace(/^>\s*/, '')
.replace(/^[-*+]\s*/, '')
.replace(/^\d+\.\s*/, '');
}
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectionRef = useRef({ start: 0, end: 0 });
const [viewMode, setViewMode] = useState<ViewMode>('split');
const [previewHtml, setPreviewHtml] = useState('');
useImperativeHandle(ref, () => ({
getHTML: () => markdownToHtml(value),
getMarkdown: () => value,
isEmpty: () => value.trim().length === 0,
focus: () => textareaRef.current?.focus(),
}));
const saveSelection = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
}, []);
const getSelection = useCallback(() => {
const ta = textareaRef.current;
if (ta && document.activeElement === ta) {
return { start: ta.selectionStart, end: ta.selectionEnd };
}
return selectionRef.current;
}, []);
const restoreSelection = useCallback((start: number, end = start) => {
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(start, end);
selectionRef.current = { start, end };
});
}, []);
// 实时预览,短 debounce 保证流畅
useEffect(() => {
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
return () => clearTimeout(t);
}, [value]);
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
const adjustTextareaHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta || viewMode === 'preview') return;
ta.style.height = '0px';
const contentHeight = ta.scrollHeight;
const top = ta.getBoundingClientRect().top;
const minHeight = Math.max(280, window.innerHeight - top - 56);
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
}, [viewMode]);
useEffect(() => {
adjustTextareaHeight();
}, [value, viewMode, adjustTextareaHeight]);
useEffect(() => {
const onResize = () => adjustTextareaHeight();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [adjustTextareaHeight]);
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
const ta = textareaRef.current;
if (!ta) return;
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
restoreSelection(start + before.length + selected.length);
}, [value, onChange, getSelection, restoreSelection]);
const wrapLine = useCallback((prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const stripped = stripLinePrefix(line);
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
onChange(next);
restoreSelection(lineStart + prefix.length + stripped.length);
}, [value, onChange, getSelection, restoreSelection]);
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
const toggleHeading = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const normalized = line.replace(/^\r/, '');
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
let nextLine: string;
if (match) {
const level = match[1].length;
const text = match[2];
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
} else {
nextLine = `## ${stripLinePrefix(normalized)}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
onChange(next);
const cursor = lineStart + nextLine.length;
restoreSelection(cursor);
}, [value, onChange, getSelection, restoreSelection]);
/** 包裹为仅登录用户可见区块 */
const wrapMembersOnly = useCallback(() => {
const { start, end } = getSelection();
if (start !== end) {
const selected = value.slice(start, end);
const block = `\n:::members\n${selected}\n:::\n`;
const next = value.slice(0, start) + block + value.slice(end);
onChange(next);
restoreSelection(start + ':::members\n'.length + 1);
return;
}
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
const tools: ToolBtn[] = [
{ icon: <strong>H</strong>, title: '标题H2再次点击升级', action: toggleHeading },
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('![', '](url)', '描述') },
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
];
const displayPreviewHtml = useMemo(() => {
if (!value.trim()) {
return `<p class="article-preview-placeholder">${placeholder}</p>`;
}
return renderPostContentHtml(previewHtml, true);
}, [value, previewHtml, placeholder]);
const words = countWords(value);
const showEdit = viewMode === 'edit' || viewMode === 'split';
const showPreview = viewMode === 'preview' || viewMode === 'split';
return (
<div className="article-editor">
<div className="article-editor-bar">
<div className="article-editor-tools">
{tools.map((t, i) => (
<button
key={i}
type="button"
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
>
{t.icon}
</button>
))}
</div>
<div className="article-editor-modes">
<button
type="button"
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
onClick={() => setViewMode('edit')}
title="仅编辑"
>
<Pencil size={14} />
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
onClick={() => setViewMode('split')}
title="分栏预览"
>
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
onClick={() => setViewMode('preview')}
title="仅预览"
>
<Eye size={14} />
</button>
</div>
</div>
<div className={`article-editor-panes article-editor-panes--${viewMode}`}>
{showEdit && (
<div className="article-pane article-pane--edit">
<textarea
ref={textareaRef}
className="article-textarea"
value={value}
onChange={e => onChange(e.target.value)}
onSelect={saveSelection}
onKeyUp={saveSelection}
onClick={saveSelection}
onFocus={saveSelection}
placeholder={placeholder}
spellCheck={false}
/>
</div>
)}
{showPreview && (
<div className="article-pane article-pane--preview">
{viewMode === 'split' && <div className="article-pane-label"></div>}
<div
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
/>
</div>
)}
</div>
<div className="article-editor-status">
<span>{words} </span>
<span>Markdown</span>
</div>
</div>
);
});
export default ArticleEditor;

View File

@@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button';
import { LayoutGrid, Folder, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boards: Board[];
loading?: boolean;
selectedId?: number;
onSelect: (id: number) => void;
}
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
const nav = useNavigate();
const { user } = useAuth();
if (loading) {
return (
<div className="board-grid board-grid--skeleton" aria-hidden>
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="board-tab board-tab--skeleton" />
))}
</div>
);
}
if (boards.length === 0) {
return (
<div className="board-grid-empty">
<p className="text-sm text-muted-foreground py-4 text-center">
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/boards')}>
<Plus />
</Button>
</div>
)}
</div>
);
}
return (
<div className="board-grid">
<button
type="button"
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
onClick={() => onSelect(0)}
>
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
<span className="board-tab-name"></span>
</button>
{boards.map(b => (
<button
key={b.id}
type="button"
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
title={b.description ? `${b.name}${b.description}` : b.name}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-icon"><Folder size={16} /></span>
<span className="board-tab-name">{b.name}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker';
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
import { commentNick } from '../utils/comment';
export interface CommentSubmitData {
content: string;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate: boolean;
}
interface Props {
user: User | null;
replyTo?: Comment | null;
inline?: boolean;
submitting?: boolean;
submitCount?: number;
onSubmit: (data: CommentSubmitData) => void;
onCancelReply?: () => void;
}
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const saved = loadGuestInfo();
const [content, setContent] = useState('');
const [guestNick, setGuestNick] = useState(saved.nick);
const [guestEmail, setGuestEmail] = useState(saved.email);
const [guestUrl, setGuestUrl] = useState(saved.url);
const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inline && replyTo) {
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
textareaRef.current?.focus({ preventScroll: true });
}
}, [replyTo?.id, inline]);
useEffect(() => {
setContent('');
setShowEmoji(false);
setIsPrivate(false);
}, [submitCount]);
useEffect(() => {
if (!showEmoji) return;
const handler = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowEmoji(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showEmoji]);
const insertEmoji = (emoji: string) => {
const el = textareaRef.current;
if (el) {
const start = el.selectionStart ?? content.length;
const end = el.selectionEnd ?? content.length;
const next = content.slice(0, start) + emoji + content.slice(end);
setContent(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + emoji.length;
el.setSelectionRange(pos, pos);
});
} else {
setContent((prev) => prev + emoji);
}
};
const handleSubmit = () => {
const text = content.trim();
if (!text) return;
if (!user && !guestNick.trim()) return;
if (!user) {
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
}
onSubmit({
content: text,
guestNick: user ? undefined : guestNick.trim(),
guestEmail: user ? undefined : guestEmail.trim(),
guestUrl: user ? undefined : guestUrl.trim(),
isPrivate,
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
return (
<div className="comment-box" ref={boxRef}>
<div className="comment-box-avatar">
{user?.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" />
) : (
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
{user ? avatarInitial : (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
)}
</div>
)}
</div>
<div className="comment-box-main">
{replyTo && !inline && (
<div className="comment-box-reply-hint">
<span> #{replyTo.floor} {commentNick(replyTo)}</span>
{onCancelReply && (
<button type="button" className="comment-box-reply-cancel" onClick={onCancelReply}></button>
)}
</div>
)}
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
<textarea
ref={textareaRef}
className="comment-box-textarea"
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
/>
<button
type="button"
className="comment-box-send"
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
onClick={handleSubmit}
title="发送"
>
<Send size={16} />
</button>
</div>
{!user && (
<div className="comment-box-guest-fields">
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-required"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="怎么称呼你"
autoComplete="nickname"
value={guestNick}
onChange={(e) => setGuestNick(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="name@example.com"
type="email"
autoComplete="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="https://example.com"
type="url"
autoComplete="url"
value={guestUrl}
onChange={(e) => setGuestUrl(e.target.value)}
/>
</label>
<p className="comment-box-guest-hint"></p>
</div>
)}
<div className="comment-box-toolbar">
<button
type="button"
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
onClick={() => setShowEmoji((v) => !v)}
>
OwO
</button>
<label className="comment-box-private">
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
<span></span>
</label>
</div>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { highlightMentions } from '../utils/content';
interface Props {
content: string;
onMentionClick?: (name: string) => void;
}
/** 渲染评论正文(支持正文内 @ 高亮) */
export default function CommentContent({ content, onMentionClick }: Props) {
return (
<div className="floor-body">
<span
dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Clock, MessageSquare, X } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Comment } from '../api/types';
import CommentContent from './CommentContent';
import {
commentNick,
commentInitial,
formatCommentDate,
isGuestComment,
buildCommentTree,
type CommentNode,
} from '../utils/comment';
interface ItemProps {
node: CommentNode;
nested?: boolean;
highlightFloor?: number | null;
replyToId?: number | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
renderReplyBox?: (comment: Comment) => ReactNode;
}
/** 单条评论(支持嵌套子回复 + 内联回复框) */
function CommentItem({
node,
nested,
highlightFloor,
replyToId,
onReply,
onCancelReply,
renderReplyBox,
}: ItemProps) {
const c = node.comment;
const nick = commentNick(c);
const guest = isGuestComment(c);
const isHighlighted = highlightFloor === c.floor;
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
return (
<div
id={`floor-${c.floor}`}
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
>
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
{c.user?.avatar ? (
<img src={c.user.avatar} alt="" />
) : (
commentInitial(c)
)}
</div>
<div className="waline-comment-main">
<div className="waline-comment-head">
{c.guest_url ? (
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
{nick}
</a>
) : (
<span className="waline-comment-author">{nick}</span>
)}
</div>
{hidden ? (
<div className="waline-comment-private-mask">
</div>
) : (
<div className="waline-comment-bubble">
{c.reply_target && (
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
)}
<CommentContent content={c.content} />
</div>
)}
<div className="waline-comment-meta">
<span className="waline-comment-date">
<Clock size={14} />
{formatCommentDate(c.created_at)}
</span>
{isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />
</button>
) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
</button>
)}
</div>
{isReplying && renderReplyBox && (
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
{renderReplyBox(c)}
</div>
)}
{node.children.length > 0 && (
<div className="waline-replies">
{node.children.map((child) => (
<CommentItem
key={child.comment.id}
node={child}
nested
highlightFloor={highlightFloor}
replyToId={replyToId}
onReply={onReply}
onCancelReply={onCancelReply}
renderReplyBox={renderReplyBox}
/>
))}
</div>
)}
</div>
</div>
);
}
interface Props {
comments: Comment[];
highlightFloor?: number | null;
replyToId?: number | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
renderReplyBox?: (comment: Comment) => ReactNode;
}
/** Waline 嵌套楼层评论列表 */
export default function CommentThreadList({
comments,
highlightFloor,
replyToId,
onReply,
onCancelReply,
renderReplyBox,
}: Props) {
const tree = buildCommentTree(comments);
return (
<div className="comment-thread-list">
{tree.map((node) => (
<CommentItem
key={node.comment.id}
node={node}
highlightFloor={highlightFloor}
replyToId={replyToId}
onReply={onReply}
onCancelReply={onCancelReply}
renderReplyBox={renderReplyBox}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { EMOJI_LIST } from '../utils/emojis';
interface Props {
onSelect: (emoji: string) => void;
}
/** OwO 表情选择面板 */
export default function EmojiPicker({ onSelect }: Props) {
return (
<div className="emoji-picker">
{EMOJI_LIST.map((e) => (
<button
key={e}
type="button"
className="emoji-picker-item"
onClick={() => onSelect(e)}
>
{e}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
interface Props { children: ReactNode }
interface State { error: Error | null }
/** 捕获渲染异常,避免整页白屏 */
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[Jiang13Forum]', error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<h3></h3>
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p>
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,68 @@
import { Button } from '@/components/ui/button';
import { Plus, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boardId: number;
keyword: string;
boards: Board[];
stats: ForumStats | null;
postTotal: number;
}
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const board = boards.find(b => b.id === boardId);
const title = keyword
? `搜索:${keyword}`
: (boardId && board ? board.name : '全部帖子');
const hint = keyword
? `找到 ${postTotal} 篇相关帖子`
: boardId && board
? (board.description || '欢迎在本板块交流讨论')
: '姜十三论坛 · 拾三一隅,自在交流';
return (
<div className="feed-banner">
<div className="feed-banner-row">
<div className="feed-banner-title">
<h2>{title}</h2>
<p>{hint}</p>
</div>
{!keyword && (
<div className="feed-actions flex flex-wrap items-center gap-2">
{authLoading ? (
<span className="feed-action-slot" aria-hidden />
) : user ? (
<Button
size="sm"
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
>
<Plus />
{boardId ? '在此发帖' : '发布帖子'}
</Button>
) : (
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/boards')}>
<Settings />
</Button>
)}
</div>
)}
</div>
<div className="feed-stats">
<span> <strong>{stats?.users ?? '—'}</strong></span>
<span> <strong>{stats?.posts ?? '—'}</strong></span>
<span> <strong>{stats?.boards ?? '—'}</strong></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
export default function PageLoader() {
return (
<div className="page-loader" role="status" aria-live="polite">
<span className="page-loader__dot" />
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { renderPostContentHtml } from '../utils/postContent';
interface Props {
html: string;
isLoggedIn: boolean;
className?: string;
}
/** 帖子正文渲染(含会员专属区块) */
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
const nav = useNavigate();
const rendered = useMemo(
() => renderPostContentHtml(html, isLoggedIn),
[html, isLoggedIn],
);
const handleClick = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-members-login]')) {
e.preventDefault();
nav('/login');
return;
}
if (target.closest('[data-members-register]')) {
e.preventDefault();
nav('/register');
}
}, [nav]);
return (
<div
className={className}
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
);
}

View File

@@ -0,0 +1,35 @@
import { Badge } from '@/components/ui/badge';
import type { PostItem } from '../api/types';
import { formatTime } from '../utils/content';
interface Props {
post: PostItem;
onClick: () => void;
}
export default function PostListItem({ post, onClick }: Props) {
const initial = post.user?.nickname?.[0] || '?';
return (
<div className="post-row" onClick={onClick}>
<div className="post-avatar">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial}
</div>
<div className="post-body">
<div className="post-title">
{post.pinned && <Badge variant="orange" className="mr-1.5"></Badge>}
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
<span>{post.user?.nickname || '匿名'}</span>
<span>{formatTime(post.created_at)}</span>
</div>
</div>
<div className="post-stats">
<span>💬 {post.comment_count ?? 0}</span>
<span>👍 {post.like_count ?? 0}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import type { PostItem, Notification, OnlineStats } from '../api/types';
interface Props {
hot: PostItem[];
notifications: Notification[];
online: OnlineStats | null;
onPostClick: (id: number) => void;
}
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? [];
return (
<>
<div className="widget-card">
<div className="widget-card-head">🔥 </div>
<div className="widget-card-body">
{hotList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">📢 </div>
<div className="widget-card-body">
{noticeList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
) : noticeList.map(item => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">👀 {online?.count ?? '—'} </div>
<div className="widget-card-body">
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{members.map(u => (
<span key={u.id} title={u.nickname} style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)',
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{u.nickname?.[0] || '?'}
</span>
))}
{members.length === 0 && (
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>线</span>
)}
</div>
</div>
</div>
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<p className="widget-about-text">
<strong></strong>
</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,87 @@
import {
Home, Settings, Star, LayoutDashboard,
} from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { openAdminDashboard } from '../utils/admin';
import { cn } from '@/lib/utils';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
}
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
if (isNeutralSidebarRoute(pathname)) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/boards')) return 'boards';
return activeBoard === 0 ? 'all' : String(activeBoard);
}
interface Props {
boards: Board[];
activeBoard: number;
onSelectBoard: (id: number) => void;
}
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
const nav = useNavigate();
const loc = useLocation();
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
<button
type="button"
key={key}
className={cn('sidebar-nav-item', menuKey != null && menuKey === key && 'active')}
onClick={onClick}
>
{icon}
<span className="flex-1 truncate">{label}</span>
</button>
);
return (
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav>
{boards.length > 0 && (
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<nav className="sidebar-nav">
{boards.map(b => (
<button
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>
))}
</nav>
</>
)}
{isAdmin && (
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<nav className="sidebar-nav">
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))}
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
</nav>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,68 @@
import { useRef, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import PostListItem from './PostListItem';
import type { PostItem } from '../api/types';
interface Props {
posts: PostItem[];
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelect: (id: number) => void;
}
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
});
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
onLoadMore();
}
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, onLoadMore]);
return (
<div className="post-list-scroll" ref={parentRef}>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
return (
<div
key={post.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} onClick={() => onSelect(post.id)} />
</div>
);
})}
</div>
{loading && (
<div className="flex justify-center py-4">
<Spinner />
</div>
)}
{!loading && !hasMore && posts.length > 0 && (
<div style={{ textAlign: 'center', padding: 6, fontSize: 12, color: 'var(--color-text-3)' }}> </div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'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',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading ? <Loader2 className="animate-spin" /> : null}
{children}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,95 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,171 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,136 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = { id: string };
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p ref={ref} id={formDescriptionId} className={cn('text-[0.8rem] text-muted-foreground', className)} {...props} />
);
},
);
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) return null;
return (
<p ref={ref} id={formMessageId} className={cn('text-[0.8rem] font-medium text-destructive', className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,20 @@
import { Toaster as Sonner } from 'sonner';
import { useTheme } from '@/hooks/useTheme';
/** 全局 Toast替代 Arco Message */
export function Toaster() {
const { theme } = useTheme();
return (
<Sonner
theme={theme}
position="top-center"
richColors
closeButton
toastOptions={{
classNames: {
toast: 'font-sans',
},
}}
/>
);
}

View File

@@ -0,0 +1,19 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SpinnerProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const sizeMap = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-8 w-8' };
/** 加载指示器,替代 Arco Spin */
export function Spinner({ className, size = 'md' }: SpinnerProps) {
return (
<Loader2
className={cn('animate-spin text-[var(--j13-green)]', sizeMap[size], className)}
aria-label="加载中"
/>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--j13-green)] data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,57 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
);
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
),
);
TableBody.displayName = 'TableBody';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
),
);
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
),
);
TableCell.displayName = 'TableCell';
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
interface AuthCtx {
user: User | null;
loading: boolean;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthCtx>({
user: null, loading: true,
refresh: async () => {}, logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await api.me();
setUser(data.user ?? null);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
const logout = async () => {
await api.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

View File

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

View File

@@ -0,0 +1,35 @@
import { createContext, useContext, useLayoutEffect, useState, ReactNode } from 'react';
import { applyTheme, getStoredTheme, type Theme } from '../utils/theme';
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
theme: 'light', toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getStoredTheme);
useLayoutEffect(() => {
applyTheme(theme);
}, [theme]);
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
export function useMediaQuery(query: string) {
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
useLayoutEffect(() => {
const m = window.matchMedia(query);
const fn = () => setMatch(m.matches);
m.addEventListener('change', fn);
return () => m.removeEventListener('change', fn);
}, [query]);
return match;
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect, useCallback, Suspense } from 'react';
import PageLoader from '../components/PageLoader';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Moon, Sun, Search, Plus } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { openAdminDashboard } from '../utils/admin';
import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { api } from '../api/client';
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme();
const isMobile = useMediaQuery('(max-width: 768px)');
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
const [hot, setHot] = useState<PostItem[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [online, setOnline] = useState<OnlineStats | null>(null);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
const refreshBoards = useCallback(() => {
Promise.all([
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
setCachedBoards(next);
return next;
}).catch(() => [] as Board[]),
api.stats().then(next => {
setStats(next);
setCachedStats(next);
return next;
}).catch(() => null),
]).finally(() => setLayoutReady(true));
}, []);
const refreshOnline = useCallback(() => {
api.online().then(d => {
setOnline({
count: d.count ?? 0,
members: d.members ?? 0,
guests: d.guests ?? 0,
users: Array.isArray(d.users) ? d.users : [],
});
}).catch(() => {});
}, []);
useEffect(() => {
refreshBoards();
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
refreshOnline();
api.presence().catch(() => {});
const onlineTimer = setInterval(refreshOnline, 30000);
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh);
return () => {
clearInterval(onlineTimer);
clearInterval(presenceTimer);
window.removeEventListener('boards-refresh', onRefresh);
};
}, [refreshBoards, refreshOnline]);
const doSearch = () => {
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
else nav('/');
};
const userInitial = user?.nickname?.charAt(0) || '?';
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
return (
<div className="app-shell">
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
<button type="button" className="header-brand" onClick={() => nav('/')}>
<span className="header-logo-mark"></span>
{!isMobile && <span className="header-logo-text"></span>}
</button>
{!isCompose && (
<div className="header-search-wrap">
<Search className="header-search-icon" size={16} />
<input
className="header-search-input"
type="search"
placeholder="搜索帖子..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && doSearch()}
/>
{keyword && (
<button
type="button"
className="header-search-clear"
onClick={() => { setKeyword(''); nav('/'); }}
aria-label="清除搜索"
>×</button>
)}
</div>
)}
<div className="header-actions">
{!isCompose && (
<button
type="button"
className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav('/login')}
>
<Plus size={16} />
{!isMobile && <span></span>}
</button>
)}
<div className="header-action-group">
<button
type="button"
className="header-icon-btn"
onClick={toggle}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname}>
{user.avatar
? <img src={user.avatar} alt="" className="header-user-avatar" />
: <span className="header-user-initial">{userInitial}</span>}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/boards')}></DropdownMenuItem>
<DropdownMenuItem onClick={openAdminDashboard}></DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
</button>
)}
</div>
</div>
</div>
</header>
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
/>
)}
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
{isMobile && !isCompose && (
<div className="mobile-board-bar">
<span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav('/'); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
>{b.name}</span>
))}
</div>
)}
<Suspense fallback={<PageLoader />}>
<Outlet context={{
boardId,
keyword: params.get('keyword') || '',
setBoardId,
boards,
stats,
layoutReady,
refreshBoards,
isMobile,
} satisfies LayoutCtx} />
</Suspense>
</main>
{!isCompose && (
<aside className="aside-panel">
<RightPanel
hot={hot}
notifications={notifications}
online={online}
onPostClick={(id) => nav(`/post/${id}`)}
/>
</aside>
)}
</div>
</div>
</div>
</div>
);
}
export type LayoutCtx = {
boardId: number;
keyword: string;
setBoardId: (id: number) => void;
boards: Board[];
stats: ForumStats | null;
layoutReady: boolean;
refreshBoards: () => void;
isMobile: boolean;
};

View File

@@ -0,0 +1,8 @@
import { toast } from 'sonner';
/** 统一 Toast 入口,替代 Arco Message */
export const notify = {
success: (message: string) => toast.success(message),
error: (message: string) => toast.error(message),
warning: (message: string) => toast.warning(message),
};

View File

@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** 合并 Tailwind class后者覆盖前者冲突项 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

12
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App';
applyTheme(getStoredTheme());
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0),
});
type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Board | null>(null);
const [submitting, setSubmitting] = useState(false);
const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 },
});
const load = () => {
setLoading(true);
api.boards()
.then(d => setBoards(d.boards ?? []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
const openCreate = () => {
setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
setModalOpen(true);
};
const openEdit = (board: Board) => {
setEditing(board);
form.reset({
name: board.name,
description: board.description ?? '',
sort_order: board.sort_order,
});
setModalOpen(true);
};
const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true);
try {
if (editing) {
await api.updateBoard(editing.id, values);
notify.success('板块已更新');
} else {
await api.createBoard(values);
notify.success('板块已创建');
}
setModalOpen(false);
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.deleteBoard(id);
notify.success('板块已删除');
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
</div>
<Button onClick={openCreate}>
<Plus />
</Button>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{boards.map(board => (
<TableRow key={board.id}>
<TableCell>{board.id}</TableCell>
<TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell>
<TableCell><Badge variant="secondary">{board.post_count ?? 0}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(board)}></Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(board.id)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{boards.length === 0 && (
<div className="empty-state">
<p></p>
</div>
)}
</>
)}
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="如:技术交流" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea rows={3} maxLength={500} placeholder="板块说明(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="number" min={0} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button type="submit" loading={submitting}>{editing ? '保存' : '创建'}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
const editId = editIdParam ? Number(editIdParam) : null;
const isEdit = editId !== null && !Number.isNaN(editId);
const [params] = useSearchParams();
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(defaultBoard);
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (isEdit) {
setLoading(true);
Promise.all([api.boards(), api.post(editId!)])
.then(([boardsData, postData]) => {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) {
notify.error('无权编辑此帖子');
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
setTitle(post.title);
setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? ''));
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
nav('/');
})
.finally(() => setLoading(false));
return;
}
api.boards().then(d => {
const list = d.boards ?? [];
setBoards(list);
if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id));
}
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!user) return null;
if (loading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!isEdit && boards.length === 0) {
return (
<div className="compose-page compose-page--empty">
<div className="compose-empty-card">
<div className="compose-empty-icon"></div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
</button>
) : (
<button type="button" className="compose-ghost-btn" onClick={() => nav('/')}>
</button>
)}
</div>
</div>
);
}
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: markdownToHtml(content),
tags: tags.trim(),
};
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
nav(`/post/${res.post_id}`);
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
} finally {
setPublishing(false);
}
};
const currentBoard = boards.find(b => String(b.id) === boardId);
return (
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
<ArrowLeft size={16} />
<span></span>
</button>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
</button>
</div>
</header>
<div className="compose-meta">
{!isEdit ? (
<div className="compose-board-pills">
{boards.map(b => (
<button
key={b.id}
type="button"
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard && (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<div className="compose-tags-field">
<Tag className="compose-tags-icon" size={16} />
<input
type="text"
placeholder="添加标签,逗号分隔"
value={tags}
onChange={e => setTags(e.target.value)}
maxLength={128}
/>
</div>
</div>
<div className="compose-writing">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={256}
/>
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
</div>
)}
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
interface FavItem {
id: number;
post_id: number;
created_at: string;
post?: {
id: number;
title: string;
board?: { name: string };
user?: { nickname: string };
};
}
export default function FavoritesPage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
}, [user, authLoading, nav]);
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
if (!user) return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<h1 className="page-title"></h1>
<p className="page-desc"> {list.length} </p>
{list.length === 0 ? (
<div className="empty-state">
<p></p>
<Button onClick={() => nav('/')}></Button>
</div>
) : (
<div className="content-surface">
{list.map(fav => (
<div
key={fav.id}
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
<div className="post-meta">
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
<span> {formatTime(fav.created_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid';
export default function HomePage() {
const nav = useNavigate();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
setPage(p);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
if (reset) setPosts([]);
} finally {
setLoading(false);
}
}, [boardId, keyword]);
useEffect(() => {
load(1, true);
}, [boardId, keyword, load]);
useEffect(() => {
const fn = () => load(1, true);
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [load]);
const showBoardGrid = !keyword;
return (
<div className="page-wrap">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showBoardGrid && (
<BoardGrid
boards={ctx?.boards ?? []}
loading={!ctx?.layoutReady}
selectedId={boardId}
onSelect={(id) => {
ctx?.setBoardId(id);
nav(id ? `/?board=${id}` : '/');
}}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
</div>
<VirtualPostList
posts={posts}
loading={loading}
hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
/>
</div>
);
}

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