支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# 运行时数据数据库、JWT 密钥、日志、头像) # 运行时数据数据库、JWT 密钥、日志、头像)
/data/ /data/
# 本地配置(保留 app.ini.example
/app.ini
# 前端依赖与构建缓存 # 前端依赖与构建缓存
/node_modules/ /node_modules/
/frontend/node_modules/ /frontend/node_modules/

View File

@@ -51,14 +51,14 @@ tidy:
## 本地运行(仅后端,使用已 embed 的前端) ## 本地运行(仅后端,使用已 embed 的前端)
run: run:
$(GO) run $(MAIN_PKG) --port 3000 --data ./data $(GO) run $(MAIN_PKG)
## 前端热更新开发(后端 :3000 + Vite :5173Ctrl+C 同时退出) ## 前端热更新开发(后端 :3000 + Vite :5173Ctrl+C 同时退出)
dev: dev:
@echo "前端热更新: http://localhost:5173" @echo "前端热更新: http://localhost:5173"
@echo "后端 API : http://localhost:3000" @echo "后端 API : http://localhost:3000"
@trap 'kill 0' INT; \ @trap 'kill 0' INT; \
$(GO) run $(MAIN_PKG) --port 3000 --data ./data & \ $(GO) run $(MAIN_PKG) & \
cd frontend && (test -d node_modules || npm install) && npm run dev cd frontend && (test -d node_modules || npm install) && npm run dev
## 清理编译产物 ## 清理编译产物

View File

@@ -100,8 +100,10 @@
### 部署体验 ### 部署体验
- **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源 - **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源
- **零依赖数据库** — SQLite 内建,数据目录 `--data`管理 - **零依赖数据库** — SQLite 内建,数据目录 `app.ini` 一管理
- **配置文件** — 工作目录下 `app.ini`(类似 Gitea启动可省略一长串参数
- **跨平台** — Windows / Linux / macOS 一键编译 - **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置注册Linux systemd / Windows Service一条命令安装与启停
--- ---
@@ -141,12 +143,22 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
### 2. 启动 ### 2. 启动
把二进制放到目标目录后直接运行即可(首次会在同目录生成 `app.ini`
```bash ```bash
# Windows # Windows
.\dist\jiang13.exe --port 3000 --data ./data .\dist\jiang13.exe
# Linux / macOS # Linux / macOS
./dist/jiang13 --port 3000 --data ./data ./dist/jiang13
```
也可先复制示例配置再改端口/数据目录:
```bash
cp app.ini.example /opt/jiang13/app.ini
# 编辑 app.ini 后:
./jiang13
``` ```
### 3. 首次使用 ### 3. 首次使用
@@ -155,13 +167,85 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
2. **第一个注册的用户自动成为管理员** 2. **第一个注册的用户自动成为管理员**
3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台 3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台
### 配置文件(`app.ini`
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录;`go run` 开发时回退为当前目录)。
```ini
[server]
HTTP_PORT = 3000
[paths]
DATA = data
[security]
JWT_SECRET =
```
完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
### 启动参数 ### 启动参数
| 参数 | 默认值 | 说明 | | 参数 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| `--port` | `3000` | HTTP 监听端口 | | `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
| `--data` | `./data` | 数据目录SQLite、上传、日志 | | `--config` | `{work-path}/app.ini` | 配置文件路径 |
| `--port` | (读配置 / `3000` | HTTP 监听端口,覆盖配置文件 |
| `--data` | (读配置 / `data` | 数据目录,覆盖配置文件 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
| `--service` | (空) | 系统服务控制:`install` / `uninstall` / `start` / `stop` / `restart` / `status` |
### 4. 注册为系统服务(可选)
将二进制与 `app.ini` 放到同一目录后注册即可。服务会绑定 `--work-path``--config`;之后改端口或数据目录只需编辑 `app.ini` 并重启服务,**不必重新安装**。
**Ubuntu / Linuxsystemd需 root**
```bash
sudo mkdir -p /opt/jiang13
sudo cp jiang13 /opt/jiang13/
# 可选:先写好配置
# sudo cp app.ini.example /opt/jiang13/app.ini
sudo /opt/jiang13/jiang13 --service install
sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13
```
常用管理:
```bash
sudo /opt/jiang13/jiang13 --service status
sudo /opt/jiang13/jiang13 --service stop
sudo /opt/jiang13/jiang13 --service restart
sudo /opt/jiang13/jiang13 --service uninstall
# 也可直接用 systemctl
sudo systemctl status jiang13
sudo journalctl -u jiang13 -f
```
**WindowsWindows Service需管理员 PowerShell**
```powershell
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
Copy-Item .\jiang13.exe C:\jiang13\
C:\jiang13\jiang13.exe --service install
C:\jiang13\jiang13.exe --service start
```
常用管理:
```powershell
C:\jiang13\jiang13.exe --service status
C:\jiang13\jiang13.exe --service stop
C:\jiang13\jiang13.exe --service restart
C:\jiang13\jiang13.exe --service uninstall
Get-Service jiang13
```
> 改 `app.ini` 后执行 `--service restart`(或 `systemctl restart jiang13` / `Restart-Service jiang13`)。
> 运行日志写入数据目录下的 `jiang13.log`Linux 上也可通过 `journalctl` 查看。
--- ---
@@ -201,8 +285,9 @@ make dev # Linux / macOS
``` ```
jiang13-forum/ jiang13-forum/
├── cmd/jiang13/ # 程序入口 ├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # 命令行参数与配置 ├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例
├── model/ # GORM 模型与数据库迁移 ├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑(认证、帖子、评论…) ├── service/ # 业务逻辑(认证、帖子、评论…)
├── handler/ # HTTP 处理器(前台 + 后台) ├── handler/ # HTTP 处理器(前台 + 后台)

14
app.ini.example Normal file
View File

@@ -0,0 +1,14 @@
; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini
; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
[server]
HTTP_PORT = 3000
[paths]
; 相对路径相对于工作目录(默认可执行文件所在目录)
DATA = data
[security]
; 留空则自动生成并持久化到 data/.jwt_secret勿把生产密钥提交到仓库
JWT_SECRET =

View File

@@ -72,7 +72,7 @@ switch ($Target) {
Write-Host '[ok] cleaned dist' -ForegroundColor Green Write-Host '[ok] cleaned dist' -ForegroundColor Green
} }
'run' { 'run' {
go run $MainPkg --port 3000 --data ./data go run $MainPkg
} }
'dev' { 'dev' {
$root = (Get-Location).Path $root = (Get-Location).Path
@@ -83,11 +83,11 @@ switch ($Target) {
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @( Start-Process powershell -ArgumentList @(
'-NoExit', '-Command', '-NoExit', '-Command',
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data" "Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg"
) | Out-Null ) | Out-Null
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
Push-Location frontend Push-Location frontend
try { 90| try {
if (-not (Test-Path node_modules)) { npm install } if (-not (Test-Path node_modules)) { npm install }
npm run dev npm run dev
} finally { } finally {

View File

@@ -1,19 +1,13 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"os" "os"
"os/signal"
"syscall" "github.com/kardianos/service"
"time"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/router"
) )
func main() { func main() {
@@ -22,55 +16,28 @@ func main() {
log.Fatalf("配置解析失败: %v", err) log.Fatalf("配置解析失败: %v", err)
} }
// 日志同时输出到控制台和文件 svcCfg, err := buildServiceConfig(cfg)
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil { if err != nil {
log.Fatalf("打开日志文件失败: %v", err) log.Fatalf("构建服务配置失败: %v", err)
}
defer logFile.Close()
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Println("========================================")
// 初始化数据库
if err := model.InitDB(cfg.DBPath()); err != nil {
log.Fatalf("数据库初始化失败: %v", err)
} }
// 设置路由 prg := &program{cfg: cfg}
engine, err := router.Setup(cfg) svc, err := service.New(prg, svcCfg)
if err != nil { if err != nil {
log.Fatalf("路由初始化失败: %v", err) log.Fatalf("创建系统服务失败: %v", err)
} }
addr := fmt.Sprintf(":%d", cfg.Port) if cfg.ServiceAction != "" {
srv := &http.Server{ if err := runServiceControl(svc, cfg.ServiceAction); err != nil {
Addr: addr, fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err)
Handler: engine, os.Exit(1)
}
// 优雅关机
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("收到关机信号,正在优雅关闭...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务关闭异常: %v", err)
} }
}() return
}
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) // 交互终端或由服务管理器拉起时均走 Run
log.Printf("数据目录: %s", cfg.DataDir) // Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
if err := svc.Run(); err != nil {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("运行失败: %v", err)
log.Fatalf("HTTP 服务异常: %v", err)
} }
log.Println("姜十三论坛已安全退出")
} }

158
cmd/jiang13/program.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/router"
)
const (
svcName = "jiang13"
svcDisplayName = "姜十三论坛"
svcDescription = "姜十三论坛 Jiang13 Forum — 轻量单二进制论坛服务"
)
// program 实现 kardianos/service.Interface兼容 Windows Service 与 Linux systemd
type program struct {
cfg *config.Config
server *http.Server
}
func (p *program) Start(s service.Service) error {
if err := p.setup(); err != nil {
return err
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP 服务异常: %v", err)
}
}()
return nil
}
func (p *program) Stop(s service.Service) error {
log.Println("收到关机信号,正在优雅关闭...")
if p.server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := p.server.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务关闭异常: %v", err)
return err
}
log.Println("姜十三论坛已安全退出")
return nil
}
func (p *program) setup() error {
cfg := p.cfg
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
if service.Interactive() {
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
} else {
log.SetOutput(logFile)
}
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Println("========================================")
if err := model.InitDB(cfg.DBPath()); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
engine, err := router.Setup(cfg)
if err != nil {
return fmt.Errorf("路由初始化失败: %w", err)
}
addr := fmt.Sprintf(":%d", cfg.Port)
p.server = &http.Server{
Addr: addr,
Handler: engine,
}
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
log.Printf("工作目录: %s", cfg.WorkPath)
log.Printf("配置文件: %s", cfg.ConfigFile)
log.Printf("数据目录: %s", cfg.DataDir)
return nil
}
func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
return &service.Config{
Name: svcName,
DisplayName: svcDisplayName,
Description: svcDescription,
WorkingDirectory: cfg.WorkPath,
Arguments: []string{
"--work-path", cfg.WorkPath,
"--config", cfg.ConfigFile,
},
Option: service.KeyValue{
// systemd异常退出后自动拉起
"Restart": "always",
// Windows崩溃后重启
"OnFailure": "restart",
},
}, nil
}
func runServiceControl(s service.Service, action string) error {
if action == "status" {
st, err := s.Status()
if err != nil {
return err
}
switch st {
case service.StatusRunning:
fmt.Println("服务状态: 运行中 (running)")
case service.StatusStopped:
fmt.Println("服务状态: 已停止 (stopped)")
default:
fmt.Println("服务状态: 未知 (unknown)")
}
return nil
}
if err := service.Control(s, action); err != nil {
return err
}
switch action {
case "install":
fmt.Println("服务已安装。可用以下命令启动:")
fmt.Printf(" %s --service start\n", os.Args[0])
fmt.Println("或使用系统工具:")
fmt.Println(" Linux: sudo systemctl start jiang13 && sudo systemctl enable jiang13")
fmt.Println(" Windows: Start-Service jiang13")
case "uninstall":
fmt.Println("服务已卸载")
case "start":
fmt.Println("服务已启动")
case "stop":
fmt.Println("服务已停止")
case "restart":
fmt.Println("服务已重启")
}
return nil
}

View File

@@ -5,61 +5,184 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// Config 应用全局配置,通过命令行参数注入 // Config 应用全局配置:默认读工作目录下 app.ini命令行可覆盖
type Config struct { type Config struct {
// 工作目录(默认可执行文件所在目录)
WorkPath string
// 配置文件绝对路径
ConfigFile string
// 监听端口 // 监听端口
Port int Port int
// 数据目录SQLite 数据库、上传头像、日志文件均存放于此 // 数据目录SQLite、上传、日志(绝对路径)
DataDir string DataDir string
// JWT 签名密钥 // JWT 签名密钥
JWTSecret string JWTSecret string
// 日志文件路径(相对 DataDir // 日志文件路径
LogFile string LogFile string
// 系统服务控制动作install|uninstall|start|stop|restart|status空表示正常运行
ServiceAction string
} }
// Parse 解析命令行参数并初始化目录 // Parse 解析命令行与 app.ini并初始化数据目录
//
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
func Parse() (*Config, error) { func Parse() (*Config, error) {
port := flag.Int("port", 3000, "HTTP 监听端口") configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini")
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志") workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录")
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化") portFlag := flag.Int("port", 0, "HTTP 监听端口覆盖配置文件0 表示不覆盖")
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
serviceFlag := flag.String("service", "", "系统服务控制install|uninstall|start|stop|restart|status")
flag.Parse() flag.Parse()
// 确保数据目录存在 action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if err := os.MkdirAll(*dataDir, 0755); err != nil { if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("创建数据目录失败: %w", err) return nil, fmt.Errorf("无效的 -service 动作 %q可选install|uninstall|start|stop|restart|status", *serviceFlag)
} }
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
if err := os.MkdirAll(uploadDir, 0755); err != nil { workPath, err := resolveWorkPath(*workFlag)
return nil, fmt.Errorf("创建上传目录失败: %w", err) if err != nil {
return nil, err
} }
postImgDir := filepath.Join(*dataDir, "uploads", "posts")
if err := os.MkdirAll(postImgDir, 0755); err != nil { configFile, err := resolveConfigPath(workPath, *configFlag)
return nil, fmt.Errorf("创建帖子图片目录失败: %w", err) if err != nil {
return nil, err
}
fileCfg := defaultFileSettings()
configExists := false
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
configExists = true
fileCfg, err = loadAppINI(configFile)
if err != nil {
return nil, err
}
}
port := fileCfg.Port
if *portFlag > 0 {
port = *portFlag
}
dataInput := fileCfg.DataRel
if strings.TrimSpace(*dataFlag) != "" {
dataInput = *dataFlag
}
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
jwtSecret := strings.TrimSpace(*jwtFlag)
if jwtSecret == "" {
jwtSecret = fileCfg.JWTSecret
} }
cfg := &Config{ cfg := &Config{
Port: *port, WorkPath: workPath,
DataDir: *dataDir, ConfigFile: configFile,
JWTSecret: *jwtSecret, Port: port,
LogFile: filepath.Join(*dataDir, "jiang13.log"), DataDir: absData,
JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
} }
// 处理 JWT 密钥持久化 needDirs := action == "" || action == "install"
secretFile := filepath.Join(*dataDir, ".jwt_secret") if needDirs {
if cfg.JWTSecret == "" { // 首次启动自动生成 app.ini便于像 Gitea 一样改文件而不记一长串参数
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { if !configExists {
cfg.JWTSecret = string(data) dataRel := resolveDataRelForINI(workPath, absData)
} else { if err := writeAppINI(configFile, port, dataRel, ""); err != nil {
cfg.JWTSecret = generateRandomSecret(32) return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600) }
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
} else if action == "install" {
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
dataRel := resolveDataRelForINI(workPath, absData)
iniJWT := ""
if strings.TrimSpace(*jwtFlag) != "" {
iniJWT = jwtSecret
}
if err := writeAppINI(configFile, port, dataRel, iniJWT); err != nil {
return nil, fmt.Errorf("更新配置文件失败: %w", err)
}
}
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
if err := cfg.resolveJWT(); err != nil {
return nil, err
} }
} }
return cfg, nil return cfg, nil
} }
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
if err != nil {
return "", fmt.Errorf("解析工作目录失败: %w", err)
}
return filepath.Clean(abs), nil
}
return defaultWorkPath()
}
func resolveConfigPath(workPath, flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
return absPath(workPath, flagVal)
}
return filepath.Join(workPath, defaultConfName), nil
}
func ensureDataDirs(dataDir string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
}
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
}
return nil
}
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
if c.JWTSecret != "" {
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
return nil
}
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
c.JWTSecret = string(data)
return nil
}
c.JWTSecret = generateRandomSecret(32)
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
return true
default:
return false
}
}
// DBPath 返回 SQLite 数据库文件路径 // DBPath 返回 SQLite 数据库文件路径
func (c *Config) DBPath() string { func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db") return filepath.Join(c.DataDir, "jiang13.db")

106
config/ini.go Normal file
View File

@@ -0,0 +1,106 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
const (
defaultPort = 3000
defaultDataRel = "data"
defaultConfName = "app.ini"
)
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
type fileSettings struct {
Port int
DataRel string
JWTSecret string
}
func defaultFileSettings() fileSettings {
return fileSettings{
Port: defaultPort,
DataRel: defaultDataRel,
}
}
func loadAppINI(path string) (fileSettings, error) {
out := defaultFileSettings()
cfg, err := ini.LoadSources(ini.LoadOptions{
IgnoreInlineComment: true,
}, path)
if err != nil {
return out, fmt.Errorf("读取配置文件失败: %w", err)
}
if sec, err := cfg.GetSection("server"); err == nil {
if k := sec.Key("HTTP_PORT"); k.String() != "" {
p, err := k.Int()
if err != nil {
return out, fmt.Errorf("server.HTTP_PORT 无效: %w", err)
}
if p <= 0 || p > 65535 {
return out, fmt.Errorf("server.HTTP_PORT 超出范围: %d", p)
}
out.Port = p
}
}
if sec, err := cfg.GetSection("paths"); err == nil {
if v := strings.TrimSpace(sec.Key("DATA").String()); v != "" {
out.DataRel = v
}
}
if sec, err := cfg.GetSection("security"); err == nil {
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
}
return out, nil
}
// writeAppINI 写入/覆盖 app.ini安装服务或首次生成时使用
func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var b strings.Builder
b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini\n")
b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
b.WriteString(";\n")
b.WriteString("; 默认位置:程序工作目录下的 app.ini\n")
b.WriteString("; 可用 --config / --work-path 覆盖。\n")
b.WriteString("\n")
b.WriteString("[server]\n")
b.WriteString("HTTP_PORT = ")
b.WriteString(strconv.Itoa(port))
b.WriteString("\n\n")
b.WriteString("[paths]\n")
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
b.WriteString("DATA = ")
b.WriteString(dataRel)
b.WriteString("\n\n")
b.WriteString("[security]\n")
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret勿把生产密钥提交到仓库\n")
b.WriteString("JWT_SECRET = ")
b.WriteString(jwtSecret)
b.WriteString("\n")
return os.WriteFile(path, []byte(b.String()), 0644)
}
// resolveDataRelForINI 把绝对数据目录尽量写成相对工作目录的路径,便于 app.ini 可读
func resolveDataRelForINI(workPath, absData string) string {
rel, err := filepath.Rel(workPath, absData)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return absData
}
return filepath.ToSlash(rel)
}

69
config/paths.go Normal file
View File

@@ -0,0 +1,69 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
func resolveAppPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
}
return exe, nil
}
// defaultWorkPath 类似 Gitea默认可执行文件所在目录
// go run 时二进制在临时目录,回退为当前工作目录。
func defaultWorkPath() (string, error) {
exe, err := resolveAppPath()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
if isEphemeralExeDir(dir) {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取当前目录失败: %w", err)
}
return filepath.Clean(wd), nil
}
return dir, nil
}
func isEphemeralExeDir(dir string) bool {
lower := strings.ToLower(filepath.Clean(dir))
sep := string(filepath.Separator)
markers := []string{
sep + "go-build",
sep + "go-run",
}
if runtime.GOOS == "windows" {
markers = append(markers, "\\go-build", "\\go-run")
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) {
return true
}
}
return false
}
func absPath(base, p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("路径为空")
}
if filepath.IsAbs(p) {
return filepath.Clean(p), nil
}
return filepath.Abs(filepath.Join(base, p))
}

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp } from 'lucide-react';
/** 滚动超过该距离后显示按钮 */
const SHOW_THRESHOLD = 320;
/** 路由切换后等待滚动容器挂载的最大重试次数 */
const BIND_RETRY_MAX = 24;
const BIND_RETRY_MS = 50;
/**
* 定位当前真正滚动的容器。
* 前台:.post-list-scroll / .page-wrap / .main-content--compose
* 后台:.admin-main
*/
function pickScrollEl(scope: ParentNode): HTMLElement | null {
const list = scope.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list;
const page = scope.querySelector<HTMLElement>('.page-wrap');
if (page) return page;
const compose = scope.querySelector<HTMLElement>('.main-content--compose');
if (compose) return compose;
return scope.querySelector<HTMLElement>('.admin-main');
}
function findScrollScope(): ParentNode | null {
return document.querySelector('.main-content')
?? document.querySelector('.admin-shell');
}
export default function BackToTop() {
const loc = useLocation();
const [visible, setVisible] = useState(false);
const scrollElRef = useRef<HTMLElement | null>(null);
const syncVisible = useCallback(() => {
const el = scrollElRef.current;
setVisible(!!el && el.scrollTop > SHOW_THRESHOLD);
}, []);
useEffect(() => {
let cancelled = false;
let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let bound: HTMLElement | null = null;
const onScroll = () => {
if (!cancelled) syncVisible();
};
const unbind = () => {
if (bound) {
bound.removeEventListener('scroll', onScroll);
bound = null;
}
scrollElRef.current = null;
};
const bind = (el: HTMLElement) => {
if (bound === el) {
onScroll();
return;
}
unbind();
bound = el;
scrollElRef.current = el;
el.addEventListener('scroll', onScroll, { passive: true });
onScroll();
};
const tryBind = () => {
if (cancelled) return;
const scope = findScrollScope();
if (!scope) {
setVisible(false);
return;
}
const next = pickScrollEl(scope);
if (next) {
bind(next);
const waitingList =
!next.classList.contains('post-list-scroll') &&
!!(scope as Element).querySelector?.('.feed-panel');
if (waitingList && attempts < BIND_RETRY_MAX) {
attempts += 1;
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
}
return;
}
unbind();
setVisible(false);
if (attempts < BIND_RETRY_MAX) {
attempts += 1;
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
}
};
tryBind();
return () => {
cancelled = true;
clearTimeout(retryTimer);
unbind();
};
}, [loc.pathname, loc.search, syncVisible]);
const scrollToTop = () => {
const el = scrollElRef.current;
if (!el) return;
el.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<button
type="button"
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
);
}

View File

@@ -35,6 +35,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const owoRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
if (inline && replyTo) { if (inline && replyTo) {
@@ -51,13 +52,24 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
useEffect(() => { useEffect(() => {
if (!showEmoji) return; if (!showEmoji) return;
const handler = (e: MouseEvent) => { const onPointer = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) { if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowEmoji(false); setShowEmoji(false);
owoRef.current?.focus();
} }
}; };
document.addEventListener('mousedown', handler); const onKey = (e: KeyboardEvent) => {
return () => document.removeEventListener('mousedown', handler); if (e.key === 'Escape') {
setShowEmoji(false);
owoRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [showEmoji]); }, [showEmoji]);
const insertEmoji = (emoji: string) => { const insertEmoji = (emoji: string) => {
@@ -108,7 +120,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<div className="comment-box" ref={boxRef}> <div className="comment-box" ref={boxRef}>
<div className="comment-box-avatar"> <div className="comment-box-avatar">
{user?.avatar ? ( {user?.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" /> <img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
) : ( ) : (
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}> <div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
{user ? avatarInitial : ( {user ? avatarInitial : (
@@ -145,6 +157,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
className="comment-box-send" className="comment-box-send"
disabled={submitting || !content.trim() || (!user && !guestNick.trim())} disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
onClick={handleSubmit} onClick={handleSubmit}
aria-label="发送评论"
title="发送" title="发送"
> >
<Send size={16} /> <Send size={16} />
@@ -200,9 +213,13 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<div className="comment-box-toolbar"> <div className="comment-box-toolbar">
<button <button
ref={owoRef}
type="button" type="button"
className={`comment-box-owo ${showEmoji ? 'active' : ''}`} className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
onClick={() => setShowEmoji((v) => !v)} onClick={() => setShowEmoji((v) => !v)}
aria-label="插入表情"
aria-expanded={showEmoji}
aria-controls="comment-emoji-picker"
> >
OwO OwO
</button> </button>
@@ -212,7 +229,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
</label> </label>
</div> </div>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />} {showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
</div> </div>
</div> </div>
); );

View File

@@ -45,7 +45,7 @@ function CommentItem({
> >
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}> <div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
{c.user?.avatar ? ( {c.user?.avatar ? (
<img src={c.user.avatar} alt="" /> <img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : ( ) : (
commentInitial(c) commentInitial(c)
)} )}

View File

@@ -1,19 +1,64 @@
import { useEffect, useId, useRef, useState } from 'react';
import { EMOJI_LIST } from '../utils/emojis'; import { EMOJI_LIST } from '../utils/emojis';
interface Props { interface Props {
onSelect: (emoji: string) => void; onSelect: (emoji: string) => void;
id?: string;
} }
/** OwO 表情选择面板 */ /** OwO 表情选择面板方向键浏览Enter 选中) */
export default function EmojiPicker({ onSelect }: Props) { export default function EmojiPicker({ onSelect, id }: Props) {
const autoId = useId();
const listId = id ?? autoId;
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[active]?.focus();
}, [active]);
const onKeyDown = (e: React.KeyboardEvent) => {
const cols = 8;
let next = active;
if (e.key === 'ArrowRight') next = Math.min(EMOJI_LIST.length - 1, active + 1);
else if (e.key === 'ArrowLeft') next = Math.max(0, active - 1);
else if (e.key === 'ArrowDown') next = Math.min(EMOJI_LIST.length - 1, active + cols);
else if (e.key === 'ArrowUp') next = Math.max(0, active - cols);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = EMOJI_LIST.length - 1;
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(EMOJI_LIST[active]);
return;
} else {
return;
}
e.preventDefault();
setActive(next);
};
return ( return (
<div className="emoji-picker"> <div
{EMOJI_LIST.map((e) => ( id={listId}
ref={listRef}
className="emoji-picker"
role="listbox"
aria-label="表情列表"
aria-activedescendant={`${listId}-opt-${active}`}
onKeyDown={onKeyDown}
>
{EMOJI_LIST.map((e, i) => (
<button <button
key={e} key={e}
id={`${listId}-opt-${i}`}
type="button" type="button"
className="emoji-picker-item" role="option"
tabIndex={active === i ? 0 : -1}
aria-selected={active === i}
className={`emoji-picker-item${active === i ? ' emoji-picker-item--active' : ''}`}
aria-label={e}
onClick={() => onSelect(e)} onClick={() => onSelect(e)}
onFocus={() => setActive(i)}
> >
{e} {e}
</button> </button>

View File

@@ -19,9 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.error) { if (this.state.error) {
return ( return (
<div style={{ padding: 24, textAlign: 'center' }}> <div className="error-boundary">
<h3></h3> <h3></h3>
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p> <p className="error-boundary-msg">{this.state.error.message}</p>
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}> <Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button> </Button>

View File

@@ -1,5 +1,7 @@
import { useRef } from 'react';
import { Clock, MessageCircle, Flame } from 'lucide-react'; import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y';
export type FeedSort = 'latest' | 'reply' | 'hot'; export type FeedSort = 'latest' | 'reply' | 'hot';
@@ -38,14 +40,35 @@ export function feedSortLabel(sort: FeedSort): string {
} }
export default function FeedSortBar({ value, onChange, postTotal }: Props) { export default function FeedSortBar({ value, onChange, postTotal }: Props) {
const listRef = useRef<HTMLDivElement>(null);
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
const onKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
if (next == null) return;
e.preventDefault();
onChange(SORT_OPTIONS[next].key);
requestAnimationFrame(() => {
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
return ( return (
<div className="feed-toolbar"> <div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序"> <div
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => ( ref={listRef}
className="feed-sort-bar"
role="tablist"
aria-label="帖子排序"
onKeyDown={onKeyDown}
>
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
<button <button
key={key} key={key}
type="button" type="button"
role="tab" role="tab"
tabIndex={activeIndex === i ? 0 : -1}
aria-selected={value === key} aria-selected={value === key}
title={`${label} · ${hint}`} title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')} className={cn('feed-sort-tab', value === key && 'active')}

View File

@@ -22,9 +22,11 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
const likeCount = post.like_count ?? 0; const likeCount = post.like_count ?? 0;
return ( return (
<div className="post-row" onClick={onClick}> <button type="button" className="post-row" onClick={onClick}>
<div className="post-avatar"> <div className="post-avatar">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial} {post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</div> </div>
<div className="post-body"> <div className="post-body">
<div className="post-title"> <div className="post-title">
@@ -47,6 +49,6 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
{likeCount} {likeCount}
</span> </span>
</div> </div>
</div> </button>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight, History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
@@ -8,6 +8,7 @@ import { api } from '../api/client';
import type { PostRevision } from '../api/types'; import type { PostRevision } from '../api/types';
import PostContent from './PostContent'; import PostContent from './PostContent';
import { formatDateTime } from '../utils/content'; import { formatDateTime } from '../utils/content';
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
import { import {
type PostSnapshot, type PostSnapshot,
htmlToDiffText, htmlToDiffText,
@@ -27,6 +28,12 @@ interface Props {
type ViewMode = 'diff' | 'before' | 'after'; type ViewMode = 'diff' | 'before' | 'after';
const VIEW_MODES = [
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const;
interface RevisionEntry { interface RevisionEntry {
rev: PostRevision; rev: PostRevision;
after: PostSnapshot; after: PostSnapshot;
@@ -105,6 +112,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff'); const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const viewTabsRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const handleClose = useCallback(() => onClose(), [onClose]);
useOverlayA11y(open, handleClose, panelRef, { initialFocusRef: closeRef });
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -124,7 +137,6 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [open, postId]); }, [open, postId]);
// 阻止背景滚动
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
@@ -166,9 +178,10 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
return ( return (
<div <div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`} className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose} onClick={handleClose}
> >
<div <div
ref={panelRef}
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`} className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
role="dialog" role="dialog"
@@ -177,7 +190,7 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
> >
<header className="post-revision-head"> <header className="post-revision-head">
<div className="post-revision-head-left"> <div className="post-revision-head-left">
<History size={18} /> <History size={18} aria-hidden />
<h3></h3> <h3></h3>
{selected && ( {selected && (
<span className="post-revision-head-sub"> <span className="post-revision-head-sub">
@@ -186,21 +199,33 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
)} )}
</div> </div>
<div className="post-revision-head-actions"> <div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist"> <div
{([ ref={viewTabsRef}
['diff', GitCompare, '变更对比'], className="post-revision-view-tabs"
['before', FileText, '编辑前'], role="tablist"
['after', ArrowRight, '编辑后'], aria-label="视图模式"
] as const).map(([mode, Icon, label]) => ( onKeyDown={(e) => {
const idx = VIEW_MODES.findIndex(([m]) => m === viewMode);
const next = moveTabIndex(e.key, Math.max(0, idx), VIEW_MODES.length);
if (next == null) return;
e.preventDefault();
setViewMode(VIEW_MODES[next][0]);
requestAnimationFrame(() => {
viewTabsRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
});
}}
>
{VIEW_MODES.map(([mode, Icon, label]) => (
<button <button
key={mode} key={mode}
type="button" type="button"
role="tab" role="tab"
tabIndex={viewMode === mode ? 0 : -1}
aria-selected={viewMode === mode} aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`} className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
> >
<Icon size={14} /> <Icon size={14} aria-hidden />
<span className="post-revision-tab-label">{label}</span> <span className="post-revision-tab-label">{label}</span>
</button> </button>
))} ))}
@@ -212,10 +237,16 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
title={fullscreen ? '退出全屏' : '全屏显示'} title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'} aria-label={fullscreen ? '退出全屏' : '全屏显示'}
> >
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />} {fullscreen ? <Minimize2 size={18} aria-hidden /> : <Maximize2 size={18} aria-hidden />}
</button> </button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭"> <button
<X size={18} /> ref={closeRef}
type="button"
className="post-revision-icon-btn"
onClick={handleClose}
aria-label="关闭"
>
<X size={18} aria-hidden />
</button> </button>
</div> </div>
</header> </header>

View File

@@ -6,6 +6,8 @@ interface Props {
notifications: Notification[]; notifications: Notification[];
online: OnlineStats | null; online: OnlineStats | null;
onPostClick: (id: number) => void; onPostClick: (id: number) => void;
/** 首次拉取中,避免空态闪烁 */
loading?: boolean;
} }
function hotRankClass(index: number): string { function hotRankClass(index: number): string {
@@ -15,7 +17,13 @@ function hotRankClass(index: number): string {
return 'widget-rank'; return 'widget-rank';
} }
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) { export default function RightPanel({
hot,
notifications,
online,
onPostClick,
loading = false,
}: Props) {
const hotList = hot?.slice(0, 8) ?? []; const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? []; const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? []; const members = online?.users ?? [];
@@ -28,13 +36,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</div> </div>
<div className="widget-card-body"> <div className="widget-card-body">
{hotList.length === 0 ? ( {loading && hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.length === 0 ? (
<div className="widget-empty"></div> <div className="widget-empty"></div>
) : hotList.map((item, i) => ( ) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}> <button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span> <span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span> <span className="widget-item-title">{item.title}</span>
</div> </button>
))} ))}
</div> </div>
</div> </div>
@@ -45,13 +60,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</div> </div>
<div className="widget-card-body"> <div className="widget-card-body">
{noticeList.length === 0 ? ( {loading && noticeList.length === 0 ? (
<div className="widget-empty"></div>
) : noticeList.length === 0 ? (
<div className="widget-empty"></div> <div className="widget-empty"></div>
) : noticeList.map(item => ( ) : noticeList.map(item => (
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}> <button
key={item.id}
type="button"
className="widget-item widget-item--notice"
onClick={() => onPostClick(item.id)}
>
<span className="widget-item-title">{item.title}</span> <span className="widget-item-title">{item.title}</span>
<span className="widget-item-time">{item.created_at}</span> <span className="widget-item-time">{item.created_at}</span>
</div> </button>
))} ))}
</div> </div>
</div> </div>
@@ -66,15 +88,21 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
{online?.members ?? 0} · {online?.guests ?? 0} {online?.members ?? 0} · {online?.guests ?? 0}
</div> </div>
<div className="widget-online-list"> <div className="widget-online-list">
{members.map(u => ( {loading && online == null ? (
<span key={u.id} className="widget-online-avatar" title={u.nickname}> <span className="widget-empty widget-empty--inline"></span>
{u.avatar ) : (
? <img src={u.avatar} alt="" /> <>
: (u.nickname?.[0] || '?')} {members.map(u => (
</span> <span key={u.id} className="widget-online-avatar" title={u.nickname}>
))} {u.avatar
{members.length === 0 && ( ? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
<span className="widget-empty widget-empty--inline">线</span> : (u.nickname?.[0] || '?')}
</span>
))}
{members.length === 0 && (
<span className="widget-empty widget-empty--inline">线</span>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -56,8 +56,8 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-section"></div> <div className="sidebar-section"></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })} {navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))} {user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
</nav> </nav>
{boards.length > 0 && ( {boards.length > 0 && (
@@ -96,9 +96,9 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
{isAdmin && ( {isAdmin && (
<> <>
<div className="sidebar-section" style={{ marginTop: 8 }}></div> <div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))} {navItem('admin', '管理后台', <LayoutDashboard aria-hidden />, () => nav('/admin/dashboard'))}
</nav> </nav>
</> </>
)} )}

View File

@@ -0,0 +1,133 @@
import { useRef, useState, type KeyboardEvent } from 'react';
import { Tag, X } from 'lucide-react';
import { notify } from '@/lib/notify';
export function parseTags(raw: string): string[] {
return raw.split(/[,]/).map((t) => t.trim()).filter(Boolean);
}
export function serializeTags(list: string[]): string {
return list.join(',');
}
interface Props {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** 序列化后的总长度上限0/undefined 表示不限 */
maxLength?: number;
disabled?: boolean;
}
/** 回车 / 逗号确认标签块,悬停显示删除 */
export default function TagInput({
value,
onChange,
placeholder = '输入标签后回车',
maxLength,
disabled,
}: Props) {
const [draft, setDraft] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const tags = parseTags(value);
const commit = (raw: string) => {
const next = raw.trim();
if (!next) return false;
if (tags.some((t) => t.toLowerCase() === next.toLowerCase())) {
setDraft('');
return false;
}
const merged = serializeTags([...tags, next]);
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
notify.warning(`标签总长不能超过 ${maxLength}`);
return false;
}
onChange(merged);
setDraft('');
return true;
};
const removeAt = (index: number) => {
onChange(serializeTags(tags.filter((_, i) => i !== index)));
inputRef.current?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',' || e.key === '') {
e.preventDefault();
commit(draft);
return;
}
if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
removeAt(tags.length - 1);
}
};
const onDraftChange = (text: string) => {
// 粘贴或输入含分隔符时立即拆成多个标签
if (/[,]/.test(text)) {
const parts = parseTags(text);
const lastSep = Math.max(text.lastIndexOf(','), text.lastIndexOf(''));
const trailing = lastSep >= 0 && lastSep === text.length - 1 ? '' : text.slice(lastSep + 1);
let list = [...tags];
for (const p of parts) {
if (list.some((t) => t.toLowerCase() === p.toLowerCase())) continue;
const merged = serializeTags([...list, p]);
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
notify.warning(`标签总长不能超过 ${maxLength}`);
break;
}
list = [...list, p];
}
onChange(serializeTags(list));
setDraft(trailing.replace(/^[,]+/, '').trimStart());
return;
}
setDraft(text);
};
return (
<div
className={`compose-tags-field${disabled ? ' compose-tags-field--disabled' : ''}`}
onClick={() => inputRef.current?.focus()}
>
<Tag className="compose-tags-icon" size={16} aria-hidden />
<div className="compose-tags-chips">
{tags.map((tag, i) => (
<span key={`${tag}-${i}`} className="compose-tag-chip">
<span className="compose-tag-chip-label">{tag}</span>
<button
type="button"
className="compose-tag-chip-remove"
aria-label={`删除标签 ${tag}`}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
removeAt(i);
}}
>
<X size={12} strokeWidth={2.5} aria-hidden />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
className="compose-tags-input"
placeholder={tags.length === 0 ? placeholder : '继续添加…'}
value={draft}
disabled={disabled}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
/>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useRef, useEffect, useLayoutEffect } from 'react'; import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem'; import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton'; import PostListSkeleton from './PostListSkeleton';
@@ -46,12 +47,17 @@ export default function VirtualPostList({
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 72, estimateSize: () => 72,
overscan: 8, overscan: 8,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
: undefined,
}); });
const showHistoryPrompt = hasMore && !canAutoLoad && !loading; const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading; const showEnd = !hasMore && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0; const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0; const isLoadingMore = loading && posts.length > 0;
const isEmpty = !loading && posts.length === 0;
useLayoutEffect(() => { useLayoutEffect(() => {
if (resetScrollKey <= 0) return; if (resetScrollKey <= 0) return;
@@ -92,6 +98,12 @@ export default function VirtualPostList({
<div className="post-list-scroll" ref={parentRef}> <div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? ( {isInitialLoad ? (
<PostListSkeleton /> <PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
</div>
) : ( ) : (
<> <>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> <div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
@@ -100,6 +112,8 @@ export default function VirtualPostList({
return ( return (
<div <div
key={post.id} key={post.id}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef, type RefObject } from 'react';
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
function listFocusable(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
}
/**
* 浮层无障碍Escape 关闭、Tab 焦点陷阱、打开时聚焦、关闭后归还焦点。
*/
export function useOverlayA11y(
open: boolean,
onClose: () => void,
containerRef: RefObject<HTMLElement | null>,
options?: {
/** 打开时优先聚焦的元素 */
initialFocusRef?: RefObject<HTMLElement | null>;
/** 关闭后是否归还焦点,默认 true */
restoreFocus?: boolean;
},
) {
const prevFocusRef = useRef<HTMLElement | null>(null);
const restore = options?.restoreFocus !== false;
useEffect(() => {
if (!open) return;
prevFocusRef.current = document.activeElement as HTMLElement | null;
const container = containerRef.current;
const initial =
options?.initialFocusRef?.current
?? container?.querySelector<HTMLElement>(FOCUSABLE)
?? null;
// 推迟到下一帧,确保抽屉 DOM 已挂载
const focusTimer = requestAnimationFrame(() => initial?.focus());
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
if (e.key !== 'Tab' || !container) return;
const nodes = listFocusable(container);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey, true);
return () => {
cancelAnimationFrame(focusTimer);
document.removeEventListener('keydown', onKey, true);
if (restore) {
prevFocusRef.current?.focus?.();
}
};
}, [open, onClose, containerRef, options?.initialFocusRef, restore]);
}
/** tablist 方向键 / Home / End 切换 */
export function moveTabIndex(
key: string,
current: number,
length: number,
): number | null {
if (length <= 0) return null;
if (key === 'ArrowRight' || key === 'ArrowDown') return (current + 1) % length;
if (key === 'ArrowLeft' || key === 'ArrowUp') return (current - 1 + length) % length;
if (key === 'Home') return 0;
if (key === 'End') return length - 1;
return null;
}

View File

@@ -1,12 +1,15 @@
import { useEffect } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, Moon, Sun, Menu, X,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y } from '../hooks/useOverlayA11y';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BackToTop from '../components/BackToTop';
const NAV = [ const NAV = [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard }, { to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
@@ -20,7 +23,17 @@ const NAV = [
/** React 管理后台布局,与前台 SPA 风格统一 */ /** React 管理后台布局,与前台 SPA 风格统一 */
export default function AdminLayout() { export default function AdminLayout() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const { theme, toggle } = useTheme();
const isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false);
const nav = useNavigate(); const nav = useNavigate();
const drawerRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const closeNav = useCallback(() => setNavOpen(false), []);
useOverlayA11y(isNarrow && navOpen, closeNav, drawerRef, {
initialFocusRef: closeRef,
});
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -34,15 +47,50 @@ export default function AdminLayout() {
} }
}, [user, loading, nav]); }, [user, loading, nav]);
useEffect(() => {
if (!isNarrow) setNavOpen(false);
}, [isNarrow]);
useEffect(() => {
if (!(isNarrow && navOpen)) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [isNarrow, navOpen]);
if (loading) { if (loading) {
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>; return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
} }
if (!user || user.role !== 'admin') return null; if (!user || user.role !== 'admin') return null;
const navLinks = NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
onClick={closeNav}
>
<Icon size={16} aria-hidden />
{label}
</NavLink>
));
return ( return (
<div className="admin-shell"> <div className="admin-shell">
<header className="admin-topbar"> <header className="admin-topbar">
<div className="admin-topbar-brand"> <div className="admin-topbar-brand">
{isNarrow && (
<button
type="button"
className="header-icon-btn"
aria-label={navOpen ? '关闭导航' : '打开导航'}
aria-expanded={navOpen}
aria-controls="admin-nav-drawer"
onClick={() => setNavOpen(v => !v)}
>
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
</button>
)}
<div className="admin-topbar-mark"></div> <div className="admin-topbar-mark"></div>
<div> <div>
<div className="admin-topbar-title"></div> <div className="admin-topbar-title"></div>
@@ -50,48 +98,77 @@ export default function AdminLayout() {
</div> </div>
</div> </div>
<div className="admin-topbar-actions"> <div className="admin-topbar-actions">
<button
type="button"
className="header-icon-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
<button type="button" className="admin-link-btn" onClick={() => nav('/')}> <button type="button" className="admin-link-btn" onClick={() => nav('/')}>
<ArrowLeft size={16} /> <ArrowLeft size={16} aria-hidden />
{!isNarrow && '返回论坛'}
</button> </button>
<span className="admin-topbar-user">{user.nickname}</span> <span className="admin-topbar-user">{user.nickname}</span>
</div> </div>
</header> </header>
<div className="admin-body"> <div className="admin-body">
<aside className="admin-sidebar"> {!isNarrow && (
{NAV.map(({ to, label, icon: Icon }) => ( <aside className="admin-sidebar">
<NavLink {navLinks}
key={to} </aside>
to={to} )}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
>
<Icon size={16} />
{label}
</NavLink>
))}
</aside>
<main className="admin-main"> <main className="admin-main">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{isNarrow && navOpen && (
<div className="admin-nav-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭导航"
tabIndex={-1}
onClick={closeNav}
/>
<aside
id="admin-nav-drawer"
ref={drawerRef}
className="admin-nav-drawer"
role="dialog"
aria-modal="true"
aria-label="管理导航"
>
<div className="admin-nav-drawer-head">
<span></span>
<button
ref={closeRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeNav}
>
<X size={18} aria-hidden />
</button>
</div>
<nav className="admin-nav-drawer-body">
{navLinks}
</nav>
</aside>
</div>
)}
<BackToTop />
</div> </div>
); );
} }
/** 管理页通用权限守卫 */ /** 管理页就绪状态(鉴权由 AdminLayout 负责,此处不再重复跳转) */
export function useAdminGuard() { export function useAdminGuard() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) nav('/login');
else if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
return { user, loading, ready: !loading && !!user && user.role === 'admin' }; return { user, loading, ready: !loading && !!user && user.role === 'admin' };
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, Suspense } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Moon, Sun, Search, Plus } from 'lucide-react'; import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,11 +11,13 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types'; import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache'; import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel'; import RightPanel from '../components/RightPanel';
import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache'; import { navigateFeed } from '../utils/feedCache';
@@ -27,6 +29,7 @@ export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)');
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const [params] = useSearchParams(); const [params] = useSearchParams();
@@ -34,17 +37,38 @@ export default function MainLayout() {
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards()); const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats()); const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
const [hot, setHot] = useState<PostItem[]>([]); const [hot, setHot] = useState<PostItem[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [online, setOnline] = useState<OnlineStats | null>(null); const [online, setOnline] = useState<OnlineStats | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [asideLoading, setAsideLoading] = useState(false);
const asideEverLoaded = useRef(false);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0); const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || ''); const [keyword, setKeyword] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort')); const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits(); const { limits: forumLimits } = useForumLimits();
const asideDrawerRef = useRef<HTMLElement>(null);
const asideCloseRef = useRef<HTMLButtonElement>(null);
const boardBarRef = useRef<HTMLDivElement>(null);
const closeAside = useCallback(() => setAsideOpen(false), []);
useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, {
initialFocusRef: asideCloseRef,
});
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]); useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
useEffect(() => {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!asideOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [asideOpen]);
const refreshBoards = useCallback(() => { const refreshBoards = useCallback(() => {
Promise.all([ Promise.all([
@@ -59,7 +83,7 @@ export default function MainLayout() {
setCachedStats(next); setCachedStats(next);
return next; return next;
}).catch(() => null), }).catch(() => null),
]).finally(() => setLayoutReady(true)); ]);
}, []); }, []);
const refreshOnline = useCallback(() => { const refreshOnline = useCallback(() => {
@@ -75,20 +99,45 @@ export default function MainLayout() {
useEffect(() => { useEffect(() => {
refreshBoards(); 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(); const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh); window.addEventListener('boards-refresh', onRefresh);
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
useEffect(() => {
if (isCompose) return;
api.presence().catch(() => {});
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
return () => clearInterval(presenceTimer);
}, [isCompose]);
const needAsideData = !isCompose && (!hideAside || asideOpen);
useEffect(() => {
if (!needAsideData) return;
let cancelled = false;
if (!asideEverLoaded.current) setAsideLoading(true);
Promise.all([
api.hotPosts().then(d => {
if (!cancelled) setHot(Array.isArray(d.posts) ? d.posts : []);
}).catch(() => {}),
api.notifications().then(d => {
if (!cancelled) setNotifications(Array.isArray(d.notifications) ? d.notifications : []);
}).catch(() => {}),
]).finally(() => {
if (!cancelled) {
asideEverLoaded.current = true;
setAsideLoading(false);
}
});
refreshOnline();
const onlineTimer = setInterval(refreshOnline, 30000);
return () => { return () => {
cancelled = true;
clearInterval(onlineTimer); clearInterval(onlineTimer);
clearInterval(presenceTimer);
window.removeEventListener('boards-refresh', onRefresh);
}; };
}, [refreshBoards, refreshOnline]); }, [needAsideData, refreshOnline]);
const doSearch = () => { const doSearch = () => {
const kw = keyword.trim(); const kw = keyword.trim();
@@ -108,9 +157,34 @@ export default function MainLayout() {
nav(`/?keyword=${encodeURIComponent(kw)}`); nav(`/?keyword=${encodeURIComponent(kw)}`);
}; };
const openPost = (id: number) => {
setAsideOpen(false);
nav(`/post/${id}`);
};
const userInitial = user?.nickname?.charAt(0) || '?'; const userInitial = user?.nickname?.charAt(0) || '?';
const isFeedHome = loc.pathname === '/';
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId; const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
const selectBoardChip = (id: number) => {
setBoardId(id);
navigateFeed(nav, buildHomeUrl(id, feedSort));
};
const onBoardBarKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeChipIndex, boardChipIds.length);
if (next == null) return;
e.preventDefault();
selectBoardChip(boardChipIds[next]);
requestAnimationFrame(() => {
const tabs = boardBarRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
return ( return (
<div className="app-shell"> <div className="app-shell">
<div className="app-frame"> <div className="app-frame">
@@ -123,11 +197,12 @@ export default function MainLayout() {
{!isCompose && ( {!isCompose && (
<div className="header-search-wrap"> <div className="header-search-wrap">
<Search className="header-search-icon" size={16} /> <Search className="header-search-icon" size={16} aria-hidden />
<input <input
className="header-search-input" className="header-search-input"
type="search" type="search"
placeholder="搜索帖子..." placeholder="搜索帖子..."
aria-label="搜索帖子"
value={keyword} value={keyword}
onChange={e => setKeyword(e.target.value)} onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
@@ -150,20 +225,36 @@ export default function MainLayout() {
type="button" type="button"
className="header-compose-btn" className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav('/login')} onClick={() => user ? nav('/compose') : nav('/login')}
aria-label="发帖"
> >
<Plus size={16} /> <Plus size={16} aria-hidden />
{!isMobile && <span></span>} {!isMobile && <span></span>}
</button> </button>
)} )}
<div className="header-action-group"> <div className="header-action-group">
{!isCompose && hideAside && (
<button
type="button"
className="header-icon-btn"
onClick={() => setAsideOpen(true)}
aria-label="打开社区动态"
aria-expanded={asideOpen}
aria-controls="aside-drawer"
title="社区动态"
>
<PanelRight size={18} aria-hidden />
</button>
)}
<button <button
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
onClick={toggle} onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'} title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
> >
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />} {theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button> </button>
{authLoading ? ( {authLoading ? (
@@ -171,9 +262,9 @@ export default function MainLayout() {
) : user ? ( ) : user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname}> <button type="button" className="header-user-btn" title={user.nickname} aria-label={`用户菜单:${user.nickname}`}>
{user.avatar {user.avatar
? <img src={user.avatar} alt="" className="header-user-avatar" /> ? <img src={user.avatar} alt="" className="header-user-avatar" loading="lazy" decoding="async" />
: <span className="header-user-initial">{userInitial}</span>} : <span className="header-user-initial">{userInitial}</span>}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -217,25 +308,40 @@ export default function MainLayout() {
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}> <div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}> <main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
{isMobile && !isCompose && ( {isMobile && !isCompose && isFeedHome && (
<div className="mobile-board-bar"> <div
<span ref={boardBarRef}
className="mobile-board-bar"
role="tablist"
aria-label="板块"
onKeyDown={onBoardBarKeyDown}
>
<button
type="button"
role="tab"
tabIndex={activeChipIndex === 0 ? 0 : -1}
aria-selected={mobileActiveBoard === 0}
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`} className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }} onClick={() => selectBoardChip(0)}
></span> ></button>
{boards.map(b => { {boards.map((b, i) => {
const themeIdx = getBoardThemeIndex(b); const themeIdx = getBoardThemeIndex(b);
const isActive = mobileActiveBoard === b.id; const isActive = mobileActiveBoard === b.id;
const idx = i + 1;
return ( return (
<span <button
key={b.id} key={b.id}
type="button"
role="tab"
tabIndex={activeChipIndex === idx ? 0 : -1}
aria-selected={isActive}
className={cn( className={cn(
'board-chip', 'board-chip',
isActive && 'active', isActive && 'active',
isActive && `board-chip--${themeIdx}`, isActive && `board-chip--${themeIdx}`,
)} )}
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }} onClick={() => selectBoardChip(b.id)}
>{b.name}</span> >{b.name}</button>
); );
})} })}
</div> </div>
@@ -247,7 +353,6 @@ export default function MainLayout() {
setBoardId, setBoardId,
boards, boards,
stats, stats,
layoutReady,
refreshBoards, refreshBoards,
isMobile, isMobile,
} satisfies LayoutCtx} /> } satisfies LayoutCtx} />
@@ -260,13 +365,58 @@ export default function MainLayout() {
hot={hot} hot={hot}
notifications={notifications} notifications={notifications}
online={online} online={online}
onPostClick={(id) => nav(`/post/${id}`)} loading={asideLoading}
onPostClick={openPost}
/> />
</aside> </aside>
)} )}
</div> </div>
</div> </div>
</div> </div>
{asideOpen && hideAside && !isCompose && (
<div className="aside-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭社区动态"
tabIndex={-1}
onClick={closeAside}
/>
<aside
id="aside-drawer"
ref={asideDrawerRef}
className="aside-drawer"
role="dialog"
aria-modal="true"
aria-label="社区动态"
>
<div className="aside-drawer-head">
<span></span>
<button
ref={asideCloseRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeAside}
>
<X size={18} aria-hidden />
</button>
</div>
<div className="aside-drawer-body">
<RightPanel
hot={hot}
notifications={notifications}
online={online}
loading={asideLoading}
onPostClick={openPost}
/>
</div>
</aside>
</div>
)}
<BackToTop />
</div> </div>
); );
} }
@@ -277,7 +427,6 @@ export type LayoutCtx = {
setBoardId: (id: number) => void; setBoardId: (id: number) => void;
boards: Board[]; boards: Board[];
stats: ForumStats | null; stats: ForumStats | null;
layoutReady: boolean;
refreshBoards: () => void; refreshBoards: () => void;
isMobile: boolean; isMobile: boolean;
}; };

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Plus } from 'lucide-react'; import { Plus, FolderKanban } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -132,7 +132,7 @@ export default function BoardsManagePage() {
return ( return (
<div className="admin-page"> <div className="admin-page">
<div className="admin-page-head"> <div className="admin-page-head">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div className="admin-page-head-row">
<div> <div>
<h1></h1> <h1></h1>
<p></p> <p></p>
@@ -209,6 +209,7 @@ export default function BoardsManagePage() {
</Table> </Table>
{boards.length === 0 && ( {boards.length === 0 && (
<div className="empty-state"> <div className="empty-state">
<FolderKanban className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p> <p></p>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom'; import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react'; import { ArrowLeft, Send, Pencil } from 'lucide-react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
@@ -10,7 +10,10 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard'; import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
import ArticleEditor from '../components/ArticleEditor'; import ArticleEditor from '../components/ArticleEditor';
import UnsavedChangesDialog from '../components/UnsavedChangesDialog'; import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
import TagInput, { serializeTags, parseTags } from '../components/TagInput';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getCachedBoards } from '../utils/layoutCache';
import type { LayoutCtx } from '../layouts/MainLayout';
interface ComposeBaseline { interface ComposeBaseline {
title: string; title: string;
@@ -19,6 +22,11 @@ interface ComposeBaseline {
boardId: string; boardId: string;
} }
function resolveBoards(ctxBoards?: Board[]): Board[] {
if (ctxBoards && ctxBoards.length > 0) return ctxBoards;
return getCachedBoards();
}
export default function ComposePage() { export default function ComposePage() {
const nav = useNavigate(); const nav = useNavigate();
const { id: editIdParam } = useParams(); const { id: editIdParam } = useParams();
@@ -28,14 +36,19 @@ export default function ComposePage() {
const defaultBoard = params.get('board') || ''; const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const layoutCtx = useOutletContext<LayoutCtx | undefined>();
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>(() => resolveBoards(layoutCtx?.boards));
const [boardId, setBoardId] = useState(defaultBoard); const [boardId, setBoardId] = useState(defaultBoard);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
const [boardsReady, setBoardsReady] = useState(
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null); const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
useEffect(() => { useEffect(() => {
@@ -44,7 +57,11 @@ export default function ComposePage() {
if (isEdit) { if (isEdit) {
setLoading(true); setLoading(true);
Promise.all([api.boards(), api.post(editId!, { skipView: true })]) const cached = resolveBoards(layoutCtx?.boards);
const boardsPromise = cached.length > 0
? Promise.resolve({ boards: cached })
: api.boards();
Promise.all([boardsPromise, api.post(editId!, { skipView: true })])
.then(([boardsData, postData]) => { .then(([boardsData, postData]) => {
const list = boardsData.boards ?? []; const list = boardsData.boards ?? [];
setBoards(list); setBoards(list);
@@ -80,11 +97,27 @@ export default function ComposePage() {
return; return;
} }
api.boards().then(d => { const list = resolveBoards(layoutCtx?.boards);
const list = d.boards ?? []; if (list.length > 0) {
setBoards(list); setBoards(list);
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : ''); setBoardsReady(true);
if (!defaultBoard && list.length > 0) { const initialBoardId = defaultBoard || String(list[0].id);
if (!defaultBoard) setBoardId(initialBoardId);
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
return;
}
setBoardsReady(false);
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
if (!defaultBoard && next.length > 0) {
setBoardId(initialBoardId); setBoardId(initialBoardId);
} }
setBaseline({ setBaseline({
@@ -93,14 +126,16 @@ export default function ComposePage() {
content: '', content: '',
boardId: initialBoardId, boardId: initialBoardId,
}); });
}).catch(() => {}); }).catch(() => {
}, [user, authLoading, nav, defaultBoard, isEdit, editId]); setBoards([]);
}).finally(() => setBoardsReady(true));
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!baseline) return false; if (!baseline) return false;
return ( return (
title !== baseline.title title !== baseline.title
|| tags !== baseline.tags || serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|| content !== baseline.content || content !== baseline.content
|| (!isEdit && boardId !== baseline.boardId) || (!isEdit && boardId !== baseline.boardId)
); );
@@ -124,7 +159,7 @@ export default function ComposePage() {
if (!user) return null; if (!user) return null;
if (loading) { if (loading || (!isEdit && !boardsReady)) {
return ( return (
<div className="compose-page compose-page--empty"> <div className="compose-page compose-page--empty">
<Spinner size="lg" /> <Spinner size="lg" />
@@ -136,7 +171,9 @@ export default function ComposePage() {
return ( return (
<div className="compose-page compose-page--empty"> <div className="compose-page compose-page--empty">
<div className="compose-empty-card"> <div className="compose-empty-card">
<div className="compose-empty-icon"></div> <div className="compose-empty-icon" aria-hidden>
<Pencil size={28} strokeWidth={1.5} />
</div>
<h2></h2> <h2></h2>
<p></p> <p></p>
{user.role === 'admin' ? ( {user.role === 'admin' ? (
@@ -164,7 +201,7 @@ export default function ComposePage() {
const payload = { const payload = {
title: trimmedTitle, title: trimmedTitle,
content: content.trim(), content: content.trim(),
tags: tags.trim(), tags: serializeTags(parseTags(tags)),
}; };
if (isEdit) { if (isEdit) {
await api.updatePost(editId!, payload); await api.updatePost(editId!, payload);
@@ -230,16 +267,12 @@ export default function ComposePage() {
<span className="compose-board-pill active">{currentBoard.name}</span> <span className="compose-board-pill active">{currentBoard.name}</span>
</div> </div>
)} )}
<div className="compose-tags-field"> <TagInput
<Tag className="compose-tags-icon" size={16} /> value={tags}
<input onChange={setTags}
type="text" placeholder="输入标签后回车"
placeholder="添加标签,逗号分隔" maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
value={tags} />
onChange={e => setTags(e.target.value)}
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
</div> </div>
<div className="compose-writing"> <div className="compose-writing">

View File

@@ -1,23 +1,19 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft, Star } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import type { PostItem } from '../api/types';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content'; import PostListItem from '../components/PostListItem';
interface FavItem { interface FavItem {
id: number; id: number;
post_id: number; post_id: number;
created_at: string; created_at: string;
post?: { post?: PostItem;
id: number;
title: string;
board?: { name: string };
user?: { nickname: string };
};
} }
export default function FavoritesPage() { export default function FavoritesPage() {
@@ -30,7 +26,7 @@ export default function FavoritesPage() {
if (authLoading) return; if (authLoading) return;
if (!user) { nav('/login'); return; } if (!user) { nav('/login'); return; }
api.favorites() api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : [])) .then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
.catch(e => notify.error(e.message)) .catch(e => notify.error(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [user, authLoading, nav]); }, [user, authLoading, nav]);
@@ -51,26 +47,31 @@ export default function FavoritesPage() {
{list.length === 0 ? ( {list.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<Star className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p> <p></p>
<Button onClick={() => nav('/')}></Button> <Button onClick={() => nav('/')}></Button>
</div> </div>
) : ( ) : (
<div className="content-surface"> <div className="content-surface">
{list.map(fav => ( {list.map(fav => (
<div fav.post ? (
key={fav.id} <PostListItem
className="post-row" key={fav.id}
onClick={() => nav(`/post/${fav.post_id}`)} post={fav.post}
> onClick={() => nav(`/post/${fav.post_id}`)}
<div className="post-body"> />
<div className="post-title">{fav.post?.title || '帖子已删除'}</div> ) : (
<div className="post-meta"> <button
{fav.post?.board?.name && <span>{fav.post.board.name}</span>} key={fav.id}
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>} type="button"
<span> {formatTime(fav.created_at)}</span> className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title"></div>
</div> </div>
</div> </button>
</div> )
))} ))}
</div> </div>
)} )}

View File

@@ -79,7 +79,7 @@ export default function LoginPage() {
</Button> </Button>
</form> </form>
</Form> </Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}> <p className="auth-footer">
<Link to="/register"></Link> <Link to="/register"></Link>
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react'; import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon'; import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -46,40 +46,51 @@ export default function PostDetailPage() {
useGlobalWheelScroll(pageRef, !loading && !!post); useGlobalWheelScroll(pageRef, !loading && !!post);
const fetchComments = useCallback(async () => { const loadSeq = useRef(0);
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
return Array.isArray(comm.comments) ? comm.comments : [];
}, [postId, user]);
const load = async () => {
if (!postId) return;
setLoading(true);
try {
const [detail, commList] = await Promise.all([
api.post(postId),
fetchComments(),
]);
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(commList);
await refresh();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
if (!postId) return;
setReplyTo(null); setReplyTo(null);
load(); const seq = ++loadSeq.current;
setLoading(true);
setPost(null);
(async () => {
try {
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
const myIds = user ? [] : loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(postId),
api.comments(postId, myIds),
]);
if (seq !== loadSeq.current) return;
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(Array.isArray(comm.comments) ? comm.comments : []);
// 会话刷新与正文展示解耦;勿作为 effect 依赖
void refresh();
} catch (e: unknown) {
if (seq !== loadSeq.current) return;
setPost(null);
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (seq === loadSeq.current) setLoading(false);
}
})();
// 仅 postId 变化时加载user/refresh 变化不得重跑
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
}, [postId]); }, [postId]);
// 发评后局部刷新评论列表(不整页重载)
const reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
}, [postId, user]);
const jumpToFloor = useCallback((floor: number) => { const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`); const el = document.getElementById(`floor-${floor}`);
if (!el) return; if (!el) return;
@@ -145,7 +156,7 @@ export default function PostDetailPage() {
setReplyTo(null); setReplyTo(null);
setSubmitCount(c => c + 1); setSubmitCount(c => c + 1);
notify.success('评论成功'); notify.success('评论成功');
setComments(await fetchComments()); await reloadComments();
setTimeout(() => jumpToFloor(r.floor), 100); setTimeout(() => jumpToFloor(r.floor), 100);
} catch (e: unknown) { } catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '评论失败'); notify.error(e instanceof Error ? e.message : '评论失败');
@@ -165,6 +176,7 @@ export default function PostDetailPage() {
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>; if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) return ( if (!post) return (
<div className="empty-state"> <div className="empty-state">
<FileQuestion className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p> <p></p>
<Button variant="outline" onClick={() => nav('/')}></Button> <Button variant="outline" onClick={() => nav('/')}></Button>
</div> </div>
@@ -226,7 +238,7 @@ export default function PostDetailPage() {
</h1> </h1>
<div className="post-detail-author-row"> <div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg"> <div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : authorInitial} {post.user?.avatar ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> : authorInitial}
</div> </div>
<div className="post-detail-author-info"> <div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span> <span className="post-detail-author-name">{post.user?.nickname}</span>
@@ -318,7 +330,7 @@ export default function PostDetailPage() {
<div className="comment-list-area"> <div className="comment-list-area">
{comments.length === 0 && !replyTo ? ( {comments.length === 0 && !replyTo ? (
<div className="comment-empty"> <div className="comment-empty">
<div className="comment-empty-icon">💬</div> <MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
<p></p> <p></p>
</div> </div>
) : ( ) : (

View File

@@ -207,7 +207,7 @@ export default function ProfilePage() {
return ( return (
<div className="page-wrap"> <div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}> <div className="page-inner-wide page-inner-wide--profile">
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}> <Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft /> <ArrowLeft />
@@ -244,7 +244,7 @@ export default function ProfilePage() {
> >
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}> <div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
{displayAvatar {displayAvatar
? <img src={displayAvatar} alt="" /> ? <img src={displayAvatar} alt="" loading="lazy" decoding="async" />
: user.nickname[0]} : user.nickname[0]}
<span className="profile-avatar-overlay"> <span className="profile-avatar-overlay">
{avatarLoading {avatarLoading
@@ -291,7 +291,7 @@ export default function ProfilePage() {
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="section-card admin-entry-card"> <div className="section-card admin-entry-card">
<div className="section-card-title"></div> <div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}> <p className="admin-entry-desc">
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -364,7 +364,7 @@ export default function ProfilePage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="至少 6 位" {...field} /> <Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -95,7 +95,7 @@ export default function RegisterPage() {
</Button> </Button>
</form> </form>
</Form> </Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}> <p className="auth-footer">
<Link to="/login"></Link> <Link to="/login"></Link>
</p> </p>
</div> </div>

View File

@@ -600,6 +600,64 @@ a:hover { text-decoration: underline; }
} }
@media (max-width: 1100px) { .aside-panel { display: none; } } @media (max-width: 1100px) { .aside-panel { display: none; } }
/* 窄屏社区动态抽屉(替代隐藏的右侧栏) */
.aside-drawer-root {
position: fixed;
inset: 0;
z-index: 95;
}
.aside-drawer-backdrop {
position: absolute;
inset: 0;
border: none;
padding: 0;
background: rgba(15, 23, 42, 0.35);
cursor: pointer;
}
.aside-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(360px, 92vw);
background: var(--j13-bg-workspace);
border-left: 1px solid var(--j13-border);
display: flex;
flex-direction: column;
box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
animation: aside-drawer-in 0.2s ease;
}
@keyframes aside-drawer-in {
from { transform: translateX(12px); opacity: 0.6; }
to { transform: translateX(0); opacity: 1; }
}
.aside-drawer-head {
height: var(--j13-header-h);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 16px;
border-bottom: 1px solid var(--j13-border);
font-size: 14px;
font-weight: 600;
background: var(--j13-bg-surface);
}
.aside-drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
@media (prefers-reduced-motion: reduce) {
.aside-drawer { animation: none; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.app-frame { border-left: none; border-right: none; } .app-frame { border-left: none; border-right: none; }
.sidebar { display: none; } .sidebar { display: none; }
@@ -607,8 +665,6 @@ a:hover { text-decoration: underline; }
.header-search-wrap { max-width: none; } .header-search-wrap { max-width: none; }
.header-compose-btn { width: 34px; padding: 0; justify-content: center; } .header-compose-btn { width: 34px; padding: 0; justify-content: center; }
.feed-banner-row { flex-direction: row; gap: 10px; } .feed-banner-row { flex-direction: row; gap: 10px; }
.board-grid { flex-wrap: nowrap; overflow-x: auto; padding: 8px 12px; scrollbar-width: none; }
.board-grid::-webkit-scrollbar { display: none; }
} }
.page-wrap { .page-wrap {
@@ -623,6 +679,80 @@ a:hover { text-decoration: underline; }
padding: 12px; padding: 12px;
} }
/* 回到顶部:贴合 app-frame 右缘,低于顶栏 / dialog */
.back-to-top {
position: fixed;
right: max(16px, calc((100vw - min(100vw, var(--j13-max-w))) / 2 + 16px));
bottom: 28px;
z-index: 90;
width: 42px;
height: 42px;
padding: 0;
border: 1px solid var(--j13-border);
border-radius: 50%;
background: var(--j13-bg-surface);
color: var(--j13-green);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--j13-shadow-card);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(10px);
transition:
opacity 0.2s ease,
transform 0.2s ease,
visibility 0.2s ease,
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.back-to-top--visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.back-to-top:hover {
background: var(--j13-green);
color: #fff;
border-color: var(--j13-green);
box-shadow: 0 3px 12px rgba(26, 127, 75, 0.28);
}
.back-to-top:active {
transform: scale(0.96);
}
@media (max-width: 768px) {
.back-to-top {
right: 14px;
bottom: 20px;
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.back-to-top {
transition: opacity 0.15s ease, visibility 0.15s ease;
transform: none;
}
.back-to-top--visible {
transform: none;
}
.back-to-top:active {
transform: none;
}
}
.feed-panel { .feed-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -679,6 +809,7 @@ a:hover { text-decoration: underline; }
.page-inner { padding: 20px 24px; max-width: 720px; } .page-inner { padding: 20px 24px; max-width: 720px; }
.page-inner-wide { padding: 20px 24px; } .page-inner-wide { padding: 20px 24px; }
.page-inner-wide--profile { max-width: 640px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: var(--color-text-3); margin: 0 0 20px; } .page-desc { font-size: 13px; color: var(--color-text-3); margin: 0 0 20px; }
@@ -915,9 +1046,17 @@ a:hover { text-decoration: underline; }
padding: 4px 12px; padding: 4px 12px;
border-radius: 16px; border-radius: 16px;
font-size: 13px; font-size: 13px;
font-family: inherit;
color: inherit;
border: 1px solid var(--j13-border-light); border: 1px solid var(--j13-border-light);
background: var(--j13-bg-block); background: var(--j13-bg-block);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.board-chip:focus-visible {
outline: 2px solid var(--j13-green);
outline-offset: 2px;
} }
.board-chip.active { .board-chip.active {
@@ -936,14 +1075,6 @@ a:hover { text-decoration: underline; }
.board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); } .board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); } .board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); }
.board-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid var(--j13-border-light);
}
/* 帖子排序栏:与左侧栏 active 样式保持一致 */ /* 帖子排序栏:与左侧栏 active 样式保持一致 */
.feed-sort-bar { .feed-sort-bar {
display: flex; display: flex;
@@ -1016,89 +1147,6 @@ a:hover { text-decoration: underline; }
} }
} }
.board-grid-empty {
padding: 20px;
border-bottom: 1px solid var(--j13-border-light);
}
.board-tab {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
max-width: 220px;
padding: 0 14px;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
background: var(--j13-bg-block);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
color: inherit;
flex-shrink: 0;
}
.board-tab:hover {
border-color: var(--j13-green-light, #7cb87c);
background: var(--j13-bg-block-muted);
}
.board-tab.active {
border-color: var(--j13-green);
background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-block));
}
.board-tab-icon {
font-size: 14px;
color: var(--j13-green);
line-height: 1;
flex-shrink: 0;
}
.board-tab-name {
font-size: 13px;
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.board-tab-count {
flex-shrink: 0;
min-width: 18px;
padding: 0 6px;
height: 20px;
line-height: 20px;
font-size: 11px;
text-align: center;
border-radius: 10px;
background: var(--color-fill-2);
color: var(--color-text-3);
}
.board-tab.active .board-tab-count {
background: color-mix(in srgb, var(--j13-green) 20%, transparent);
color: var(--j13-green);
}
.board-tab--skeleton {
width: 140px;
border-color: transparent;
background: linear-gradient(90deg, var(--color-fill-2) 25%, var(--color-fill-3) 50%, var(--color-fill-2) 75%);
background-size: 200% 100%;
animation: board-skeleton-shimmer 1.2s ease-in-out infinite;
pointer-events: none;
}
.board-grid--skeleton { pointer-events: none; }
@keyframes board-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.post-list-bar { .post-list-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1112,8 +1160,15 @@ a:hover { text-decoration: underline; }
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
width: 100%;
padding: 12px 20px; padding: 12px 20px;
border: none;
border-bottom: 1px solid var(--j13-border-light); border-bottom: 1px solid var(--j13-border-light);
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.15s, box-shadow 0.15s; transition: background 0.15s, box-shadow 0.15s;
position: relative; position: relative;
@@ -1136,11 +1191,19 @@ a:hover { text-decoration: underline; }
background: var(--j13-bg-block-accent); background: var(--j13-bg-block-accent);
} }
.post-row:hover::before { .post-row:hover::before,
.post-row:focus-visible::before {
opacity: 1; opacity: 1;
} }
.post-row:hover .post-title { .post-row:focus-visible {
outline: none;
background: var(--j13-bg-block-accent);
box-shadow: inset 0 0 0 2px var(--j13-green-bg);
}
.post-row:hover .post-title,
.post-row:focus-visible .post-title {
color: var(--j13-green); color: var(--j13-green);
} }
@@ -1161,7 +1224,8 @@ a:hover { text-decoration: underline; }
transition: box-shadow 0.15s; transition: box-shadow 0.15s;
} }
.post-row:hover .post-avatar { .post-row:hover .post-avatar,
.post-row:focus-visible .post-avatar {
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green); box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
} }
@@ -1210,7 +1274,8 @@ a:hover { text-decoration: underline; }
flex-shrink: 0; flex-shrink: 0;
} }
.post-row:hover .post-stat { .post-row:hover .post-stat,
.post-row:focus-visible .post-stat {
background: var(--j13-green-bg); background: var(--j13-green-bg);
color: var(--j13-green); color: var(--j13-green);
} }
@@ -1445,15 +1510,72 @@ a:hover { text-decoration: underline; }
border-radius: 999px; border-radius: 999px;
} }
.empty-state { text-align: center; padding: 60px 24px; color: var(--color-text-3); } .empty-state {
text-align: center;
padding: 60px 24px;
color: var(--color-text-3);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.empty-feed { .empty-feed {
text-align: center; text-align: center;
padding: 48px 24px; padding: 48px 24px;
color: var(--color-text-3); color: var(--color-text-3);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
} }
.empty-feed-icon { font-size: 36px; margin-bottom: 8px; } .empty-state-icon,
.empty-feed-icon,
.comment-empty-icon {
display: block;
color: var(--color-text-4);
margin-bottom: 4px;
opacity: 0.9;
}
.comment-empty-icon {
margin: 0 auto 8px;
}
.empty-feed-hint {
margin: 0;
font-size: 12px;
color: var(--color-text-4);
}
.admin-entry-desc {
font-size: 13px;
color: var(--color-text-3);
margin: 0 0 12px;
}
.sidebar-section--spaced {
margin-top: 8px;
}
.error-boundary {
padding: 24px;
text-align: center;
}
.error-boundary-msg {
color: var(--color-text-3);
font-size: 13px;
margin: 8px 0 16px;
}
.auth-footer {
text-align: center;
margin-top: 16px;
font-size: 13px;
color: var(--color-text-3);
}
.post-detail-loading { .post-detail-loading {
display: flex; display: flex;
@@ -2472,7 +2594,6 @@ a:hover { text-decoration: underline; }
color: var(--color-text-3); color: var(--color-text-3);
} }
.comment-empty-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.6; }
.comment-empty p { margin: 0; font-size: 13px; } .comment-empty p { margin: 0; font-size: 13px; }
/* 回复栏(旧版保留兼容) */ /* 回复栏(旧版保留兼容) */
@@ -2751,7 +2872,16 @@ a:hover { text-decoration: underline; }
transition: background 0.1s; transition: background 0.1s;
} }
.emoji-picker-item:hover { background: var(--color-fill-2); } .emoji-picker-item:hover,
.emoji-picker-item:focus-visible,
.emoji-picker-item--active {
background: var(--color-fill-2);
outline: none;
}
.emoji-picker-item:focus-visible {
box-shadow: inset 0 0 0 2px var(--j13-green);
}
/* Waline 嵌套评论列表 — 与正文共用 .page-wrap 滚动 */ /* Waline 嵌套评论列表 — 与正文共用 .page-wrap 滚动 */
@@ -3023,10 +3153,16 @@ a.waline-comment-author:hover { color: var(--j13-green); }
} }
.widget-item { .widget-item {
width: 100%;
padding: 7px 0; padding: 7px 0;
font-size: 13px; font-size: 13px;
font-family: inherit;
color: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
border: none;
border-bottom: 1px solid var(--j13-border-light); border-bottom: 1px solid var(--j13-border-light);
background: transparent;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -3036,7 +3172,8 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.widget-item:last-child { border-bottom: none; } .widget-item:last-child { border-bottom: none; }
.widget-item:hover { .widget-item:hover,
.widget-item:focus-visible {
color: var(--j13-green); color: var(--j13-green);
background: var(--j13-bg-block-accent); background: var(--j13-bg-block-accent);
padding-left: 6px; padding-left: 6px;
@@ -3045,6 +3182,11 @@ a.waline-comment-author:hover { color: var(--j13-green); }
margin-right: -6px; margin-right: -6px;
} }
.widget-item:focus-visible {
outline: 2px solid var(--j13-green);
outline-offset: 1px;
}
.widget-item-title { .widget-item-title {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@@ -3436,7 +3578,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
border-radius: 14px; border-radius: 14px;
background: var(--j13-green-bg); background: var(--j13-green-bg);
color: var(--j13-green); color: var(--j13-green);
font-size: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -3583,13 +3724,15 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.compose-tags-field { .compose-tags-field {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 6px 10px 6px 14px;
border: 1px solid var(--j13-border); border: 1px solid var(--j13-border);
border-radius: 20px; border-radius: 20px;
background: var(--j13-bg-surface); background: var(--j13-bg-surface);
min-width: 220px; min-width: 220px;
max-width: 100%;
cursor: text;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
@@ -3597,23 +3740,111 @@ a.waline-comment-author:hover { color: var(--j13-green); }
border-color: var(--j13-green); border-color: var(--j13-green);
} }
.compose-tags-icon { .compose-tags-field--disabled {
color: var(--color-text-4); opacity: 0.6;
font-size: 14px; cursor: not-allowed;
flex-shrink: 0;
} }
.compose-tags-field input { .compose-tags-icon {
color: var(--color-text-4);
flex-shrink: 0;
margin-top: 5px;
}
.compose-tags-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex: 1; flex: 1;
min-width: 0;
}
.compose-tag-chip {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
height: 26px;
padding: 0 10px;
border-radius: 999px;
background: var(--j13-green-bg);
color: var(--j13-green);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.compose-tag-chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.compose-tag-chip-remove {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid var(--j13-bg-surface);
border-radius: 50%;
background: var(--color-text-2);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: scale(0.85);
pointer-events: none;
transition: opacity 0.12s ease, transform 0.12s ease, background 0.12s ease;
}
.compose-tag-chip:hover .compose-tag-chip-remove,
.compose-tag-chip:focus-within .compose-tag-chip-remove {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.compose-tag-chip-remove:hover {
background: #e53935;
}
.compose-tag-chip-remove:focus-visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
outline: 2px solid var(--j13-green);
outline-offset: 1px;
}
/* 触控设备无悬停:始终显示删除按钮 */
@media (hover: none) {
.compose-tag-chip-remove {
opacity: 0.85;
transform: scale(1);
pointer-events: auto;
}
}
.compose-tags-input {
flex: 1;
min-width: 96px;
height: 26px;
border: none; border: none;
background: transparent; background: transparent;
outline: none; outline: none;
font-size: 13px; font-size: 13px;
font-family: inherit;
color: var(--color-text-1); color: var(--color-text-1);
min-width: 0; padding: 0 2px;
} }
.compose-tags-field input::placeholder { .compose-tags-input::placeholder {
color: var(--color-text-4); color: var(--color-text-4);
} }
@@ -4297,7 +4528,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; } .admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
.admin-card { .admin-card {
border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card)); border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card));
margin-bottom: 16px; margin-bottom: 16px; overflow-x: auto;
} }
.admin-card-body { padding: 16px 20px 20px; } .admin-card-body { padding: 16px 20px 20px; }
.admin-card-head { .admin-card-head {
@@ -4498,6 +4729,83 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.admin-settings-bar p { max-width: none; } .admin-settings-bar p { max-width: none; }
.admin-settings-info-row { grid-template-columns: 1fr; gap: 4px; } .admin-settings-info-row { grid-template-columns: 1fr; gap: 4px; }
} }
@media (max-width: 768px) {
.admin-body {
height: calc(100dvh - 56px);
}
.admin-main {
padding: 16px;
}
.admin-topbar {
padding: 0 12px;
gap: 8px;
}
.admin-topbar-brand {
gap: 8px;
min-width: 0;
}
.admin-topbar-sub { display: none; }
.admin-dl {
grid-template-columns: 1fr;
}
}
/* 后台窄屏导航抽屉(与前台 aside-drawer 同范式) */
.admin-nav-drawer-root {
position: fixed;
inset: 0;
z-index: 95;
}
.admin-nav-drawer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: min(280px, 88vw);
background: hsl(var(--card));
border-right: 1px solid var(--j13-border);
display: flex;
flex-direction: column;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.12);
animation: admin-nav-drawer-in 0.2s ease;
}
@keyframes admin-nav-drawer-in {
from { transform: translateX(-12px); opacity: 0.6; }
to { transform: translateX(0); opacity: 1; }
}
.admin-nav-drawer-head {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 16px;
border-bottom: 1px solid var(--j13-border);
font-size: 14px;
font-weight: 600;
}
.admin-nav-drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 10px;
}
@media (prefers-reduced-motion: reduce) {
.admin-nav-drawer { animation: none; }
}
.admin-page-head-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; } .admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); } .admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); } .admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }

View File

@@ -91,5 +91,10 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`; el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
}); });
doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
});
return doc.body.innerHTML; return doc.body.innerHTML;
} }

2
go.mod
View File

@@ -5,7 +5,9 @@ go 1.26
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/kardianos/service v1.2.2
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
gopkg.in/ini.v1 v1.67.3
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )

8
go.sum
View File

@@ -36,6 +36,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -65,8 +67,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -78,6 +81,7 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
@@ -90,6 +94,8 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -386,6 +386,6 @@ function sleep(ms) {
main().catch((err) => { main().catch((err) => {
console.error('\n种子失败', err.message); console.error('\n种子失败', err.message);
console.error('请确认服务已启动,例如:.\\dist\\jiang13.exe --port 3000 --data ./data'); console.error('请确认服务已启动,例如:.\\dist\\jiang13.exe');
process.exit(1); process.exit(1);
}); });