From 9487c8ab027af035a3aac2f044cf019ddf7d23ff Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 29 Jul 2026 14:09:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20app.ini=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E7=B3=BB=E7=BB=9F=E6=9C=8D=E5=8A=A1=E5=AE=89?= =?UTF-8?q?=E8=A3=85=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E4=B8=8E=E6=97=A0=E9=9A=9C=E7=A2=8D=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。 Co-authored-by: Cursor --- .gitignore | 3 + Makefile | 4 +- README.md | 99 +++- app.ini.example | 14 + build.ps1 | 6 +- cmd/jiang13/main.go | 69 +-- cmd/jiang13/program.go | 158 ++++++ config/config.go | 179 +++++- config/ini.go | 106 ++++ config/paths.go | 69 +++ frontend/src/components/BackToTop.tsx | 125 ++++ frontend/src/components/CommentBox.tsx | 27 +- frontend/src/components/CommentThreadList.tsx | 2 +- frontend/src/components/EmojiPicker.tsx | 55 +- frontend/src/components/ErrorBoundary.tsx | 4 +- frontend/src/components/FeedSortBar.tsx | 27 +- frontend/src/components/PostListItem.tsx | 8 +- frontend/src/components/PostRevisionPanel.tsx | 59 +- frontend/src/components/RightPanel.tsx | 60 +- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/components/TagInput.tsx | 133 +++++ frontend/src/components/VirtualPostList.tsx | 14 + frontend/src/hooks/useOverlayA11y.ts | 85 +++ frontend/src/layouts/AdminLayout.tsx | 133 ++++- frontend/src/layouts/MainLayout.tsx | 209 ++++++- frontend/src/pages/BoardsManagePage.tsx | 5 +- frontend/src/pages/ComposePage.tsx | 81 ++- frontend/src/pages/FavoritesPage.tsx | 45 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/PostDetailPage.tsx | 78 +-- frontend/src/pages/ProfilePage.tsx | 8 +- frontend/src/pages/RegisterPage.tsx | 2 +- frontend/src/styles/global.css | 534 ++++++++++++++---- frontend/src/utils/postContent.ts | 5 + go.mod | 2 + go.sum | 8 +- scripts/seed.mjs | 2 +- 37 files changed, 2025 insertions(+), 403 deletions(-) create mode 100644 app.ini.example create mode 100644 cmd/jiang13/program.go create mode 100644 config/ini.go create mode 100644 config/paths.go create mode 100644 frontend/src/components/BackToTop.tsx create mode 100644 frontend/src/components/TagInput.tsx create mode 100644 frontend/src/hooks/useOverlayA11y.ts diff --git a/.gitignore b/.gitignore index 25dcb76..0f5d4c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # 运行时数据(数据库、JWT 密钥、日志、头像) /data/ +# 本地配置(保留 app.ini.example) +/app.ini + # 前端依赖与构建缓存 /node_modules/ /frontend/node_modules/ diff --git a/Makefile b/Makefile index acbbad5..9742b7b 100644 --- a/Makefile +++ b/Makefile @@ -51,14 +51,14 @@ tidy: ## 本地运行(仅后端,使用已 embed 的前端) run: - $(GO) run $(MAIN_PKG) --port 3000 --data ./data + $(GO) run $(MAIN_PKG) ## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出) dev: @echo "前端热更新: http://localhost:5173" @echo "后端 API : http://localhost:3000" @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 ## 清理编译产物 diff --git a/README.md b/README.md index dc41552..f585354 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ ### 部署体验 - **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源 -- **零依赖数据库** — SQLite 内建,数据目录 `--data` 一处管理 +- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 +- **配置文件** — 工作目录下 `app.ini`(类似 Gitea),启动可省略一长串参数 - **跨平台** — Windows / Linux / macOS 一键编译 +- **系统服务** — 内置注册:Linux systemd / Windows Service,一条命令安装与启停 --- @@ -141,12 +143,22 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 ### 2. 启动 +把二进制放到目标目录后直接运行即可(首次会在同目录生成 `app.ini`): + ```bash # Windows -.\dist\jiang13.exe --port 3000 --data ./data +.\dist\jiang13.exe # Linux / macOS -./dist/jiang13 --port 3000 --data ./data +./dist/jiang13 +``` + +也可先复制示例配置再改端口/数据目录: + +```bash +cp app.ini.example /opt/jiang13/app.ini +# 编辑 app.ini 后: +./jiang13 ``` ### 3. 首次使用 @@ -155,13 +167,85 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 2. **第一个注册的用户自动成为管理员** 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 监听端口 | -| `--data` | `./data` | 数据目录(SQLite、上传、日志) | +| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) | +| `--config` | `{work-path}/app.ini` | 配置文件路径 | +| `--port` | (读配置 / `3000`) | HTTP 监听端口,覆盖配置文件 | +| `--data` | (读配置 / `data`) | 数据目录,覆盖配置文件 | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | +| `--service` | (空) | 系统服务控制:`install` / `uninstall` / `start` / `stop` / `restart` / `status` | + +### 4. 注册为系统服务(可选) + +将二进制与 `app.ini` 放到同一目录后注册即可。服务会绑定 `--work-path` 与 `--config`;之后改端口或数据目录只需编辑 `app.ini` 并重启服务,**不必重新安装**。 + +**Ubuntu / Linux(systemd,需 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 +``` + +**Windows(Windows 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/ -├── cmd/jiang13/ # 程序入口 -├── config/ # 命令行参数与配置 +├── cmd/jiang13/ # 程序入口(含系统服务注册) +├── config/ # app.ini 与命令行配置 +├── app.ini.example # 配置文件示例 ├── model/ # GORM 模型与数据库迁移 ├── service/ # 业务逻辑(认证、帖子、评论…) ├── handler/ # HTTP 处理器(前台 + 后台) diff --git a/app.ini.example b/app.ini.example new file mode 100644 index 0000000..fceaa87 --- /dev/null +++ b/app.ini.example @@ -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 = diff --git a/build.ps1 b/build.ps1 index 1e222bc..8585ee6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -72,7 +72,7 @@ switch ($Target) { Write-Host '[ok] cleaned dist' -ForegroundColor Green } 'run' { - go run $MainPkg --port 3000 --data ./data + go run $MainPkg } 'dev' { $root = (Get-Location).Path @@ -83,11 +83,11 @@ switch ($Target) { Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan Start-Process powershell -ArgumentList @( '-NoExit', '-Command', - "Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data" + "Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg" ) | Out-Null Start-Sleep -Seconds 2 Push-Location frontend - try { + 90| try { if (-not (Test-Path node_modules)) { npm install } npm run dev } finally { diff --git a/cmd/jiang13/main.go b/cmd/jiang13/main.go index a238376..cd2229c 100644 --- a/cmd/jiang13/main.go +++ b/cmd/jiang13/main.go @@ -1,19 +1,13 @@ package main import ( - "context" "fmt" - "io" "log" - "net/http" "os" - "os/signal" - "syscall" - "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" ) func main() { @@ -22,55 +16,28 @@ func main() { log.Fatalf("配置解析失败: %v", err) } - // 日志同时输出到控制台和文件 - logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + svcCfg, err := buildServiceConfig(cfg) if err != nil { - log.Fatalf("打开日志文件失败: %v", err) - } - defer logFile.Close() - log.SetOutput(io.MultiWriter(os.Stdout, logFile)) - log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) - - log.Println("========================================") - log.Println(" 姜十三论坛 Jiang13 Forum 启动中...") - log.Println("========================================") - - // 初始化数据库 - if err := model.InitDB(cfg.DBPath()); err != nil { - log.Fatalf("数据库初始化失败: %v", err) + log.Fatalf("构建服务配置失败: %v", err) } - // 设置路由 - engine, err := router.Setup(cfg) + prg := &program{cfg: cfg} + svc, err := service.New(prg, svcCfg) if err != nil { - log.Fatalf("路由初始化失败: %v", err) + log.Fatalf("创建系统服务失败: %v", err) } - addr := fmt.Sprintf(":%d", cfg.Port) - srv := &http.Server{ - Addr: addr, - Handler: engine, - } - - // 优雅关机 - go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - log.Println("收到关机信号,正在优雅关闭...") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - log.Printf("HTTP 服务关闭异常: %v", err) + if cfg.ServiceAction != "" { + if err := runServiceControl(svc, cfg.ServiceAction); err != nil { + fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err) + os.Exit(1) } - }() - - log.Printf("姜十三论坛已启动: http://localhost%s", addr) - log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) - log.Printf("数据目录: %s", cfg.DataDir) - - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("HTTP 服务异常: %v", err) + return + } + + // 交互终端或由服务管理器拉起时均走 Run: + // Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM + if err := svc.Run(); err != nil { + log.Fatalf("运行失败: %v", err) } - log.Println("姜十三论坛已安全退出") } diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go new file mode 100644 index 0000000..6ae794e --- /dev/null +++ b/cmd/jiang13/program.go @@ -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 +} diff --git a/config/config.go b/config/config.go index ffa0ed7..a5a9333 100644 --- a/config/config.go +++ b/config/config.go @@ -5,61 +5,184 @@ import ( "fmt" "os" "path/filepath" + "strings" ) -// Config 应用全局配置,通过命令行参数注入 +// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖 type Config struct { + // 工作目录(默认可执行文件所在目录) + WorkPath string + // 配置文件绝对路径 + ConfigFile string // 监听端口 Port int - // 数据目录:SQLite 数据库、上传头像、日志文件均存放于此 + // 数据目录:SQLite、上传、日志(绝对路径) DataDir string // JWT 签名密钥 JWTSecret string - // 日志文件路径(相对 DataDir) + // 日志文件路径 LogFile string + // 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行 + ServiceAction string } -// Parse 解析命令行参数并初始化目录 +// Parse 解析命令行与 app.ini,并初始化数据目录 +// +// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值 func Parse() (*Config, error) { - port := flag.Int("port", 3000, "HTTP 监听端口") - dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志)") - jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化)") + configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)") + workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)") + portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)") + dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)") + jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)") + serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status") flag.Parse() - // 确保数据目录存在 - if err := os.MkdirAll(*dataDir, 0755); err != nil { - return nil, fmt.Errorf("创建数据目录失败: %w", err) + action := strings.ToLower(strings.TrimSpace(*serviceFlag)) + if action != "" && !validServiceAction(action) { + 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 { - return nil, fmt.Errorf("创建上传目录失败: %w", err) + + workPath, err := resolveWorkPath(*workFlag) + if err != nil { + return nil, err } - postImgDir := filepath.Join(*dataDir, "uploads", "posts") - if err := os.MkdirAll(postImgDir, 0755); err != nil { - return nil, fmt.Errorf("创建帖子图片目录失败: %w", err) + + configFile, err := resolveConfigPath(workPath, *configFlag) + 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{ - Port: *port, - DataDir: *dataDir, - JWTSecret: *jwtSecret, - LogFile: filepath.Join(*dataDir, "jiang13.log"), + WorkPath: workPath, + ConfigFile: configFile, + Port: port, + DataDir: absData, + JWTSecret: jwtSecret, + LogFile: filepath.Join(absData, "jiang13.log"), + ServiceAction: action, } - // 处理 JWT 密钥持久化 - secretFile := filepath.Join(*dataDir, ".jwt_secret") - if cfg.JWTSecret == "" { - if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { - cfg.JWTSecret = string(data) - } else { - cfg.JWTSecret = generateRandomSecret(32) - _ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600) + needDirs := action == "" || action == "install" + if needDirs { + // 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数 + if !configExists { + dataRel := resolveDataRelForINI(workPath, absData) + if err := writeAppINI(configFile, port, dataRel, ""); err != nil { + return nil, fmt.Errorf("生成默认配置文件失败: %w", err) + } + fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile) + } else if action == "install" { + // 安装服务前把当前生效配置写回,避免服务只读旧 app.ini + dataRel := resolveDataRelForINI(workPath, absData) + iniJWT := "" + 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 } +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 数据库文件路径 func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "jiang13.db") diff --git a/config/ini.go b/config/ini.go new file mode 100644 index 0000000..d448499 --- /dev/null +++ b/config/ini.go @@ -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) +} diff --git a/config/paths.go b/config/paths.go new file mode 100644 index 0000000..98ef06b --- /dev/null +++ b/config/paths.go @@ -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)) +} diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx new file mode 100644 index 0000000..ac81165 --- /dev/null +++ b/frontend/src/components/BackToTop.tsx @@ -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('.post-list-scroll'); + if (list) return list; + const page = scope.querySelector('.page-wrap'); + if (page) return page; + const compose = scope.querySelector('.main-content--compose'); + if (compose) return compose; + return scope.querySelector('.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(null); + + const syncVisible = useCallback(() => { + const el = scrollElRef.current; + setVisible(!!el && el.scrollTop > SHOW_THRESHOLD); + }, []); + + useEffect(() => { + let cancelled = false; + let attempts = 0; + let retryTimer: ReturnType | 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 ( + + ); +} diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index 58ed744..adc1e9a 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -35,6 +35,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo const [showEmoji, setShowEmoji] = useState(false); const textareaRef = useRef(null); const boxRef = useRef(null); + const owoRef = useRef(null); useEffect(() => { if (inline && replyTo) { @@ -51,13 +52,24 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo useEffect(() => { if (!showEmoji) return; - const handler = (e: MouseEvent) => { + const onPointer = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) { setShowEmoji(false); + owoRef.current?.focus(); } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setShowEmoji(false); + owoRef.current?.focus(); + } + }; + document.addEventListener('mousedown', onPointer); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('keydown', onKey); + }; }, [showEmoji]); const insertEmoji = (emoji: string) => { @@ -108,7 +120,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
{user?.avatar ? ( - + ) : (
{user ? avatarInitial : ( @@ -145,6 +157,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo className="comment-box-send" disabled={submitting || !content.trim() || (!user && !guestNick.trim())} onClick={handleSubmit} + aria-label="发送评论" title="发送" > @@ -200,9 +213,13 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
@@ -212,7 +229,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
- {showEmoji && } + {showEmoji && }
); diff --git a/frontend/src/components/CommentThreadList.tsx b/frontend/src/components/CommentThreadList.tsx index 1b46f54..e4f1fa3 100644 --- a/frontend/src/components/CommentThreadList.tsx +++ b/frontend/src/components/CommentThreadList.tsx @@ -45,7 +45,7 @@ function CommentItem({ >
{c.user?.avatar ? ( - + ) : ( commentInitial(c) )} diff --git a/frontend/src/components/EmojiPicker.tsx b/frontend/src/components/EmojiPicker.tsx index 1dd8a7d..29b5f0c 100644 --- a/frontend/src/components/EmojiPicker.tsx +++ b/frontend/src/components/EmojiPicker.tsx @@ -1,19 +1,64 @@ +import { useEffect, useId, useRef, useState } from 'react'; import { EMOJI_LIST } from '../utils/emojis'; interface Props { onSelect: (emoji: string) => void; + id?: string; } -/** OwO 表情选择面板 */ -export default function EmojiPicker({ onSelect }: Props) { +/** OwO 表情选择面板(方向键浏览,Enter 选中) */ +export default function EmojiPicker({ onSelect, id }: Props) { + const autoId = useId(); + const listId = id ?? autoId; + const [active, setActive] = useState(0); + const listRef = useRef(null); + + useEffect(() => { + listRef.current?.querySelectorAll('[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 ( -
- {EMOJI_LIST.map((e) => ( +
+ {EMOJI_LIST.map((e, i) => ( diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 42f6750..1a366ef 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -19,9 +19,9 @@ export default class ErrorBoundary extends Component { render() { if (this.state.error) { return ( -
+

页面渲染出错

-

{this.state.error.message}

+

{this.state.error.message}

diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx index 0a9a0d5..4ded22b 100644 --- a/frontend/src/components/FeedSortBar.tsx +++ b/frontend/src/components/FeedSortBar.tsx @@ -1,5 +1,7 @@ +import { useRef } from 'react'; import { Clock, MessageCircle, Flame } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { moveTabIndex } from '../hooks/useOverlayA11y'; export type FeedSort = 'latest' | 'reply' | 'hot'; @@ -38,14 +40,35 @@ export function feedSortLabel(sort: FeedSort): string { } export default function FeedSortBar({ value, onChange, postTotal }: Props) { + const listRef = useRef(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('[role="tab"]'); + tabs?.[next]?.focus(); + }); + }; + return (
-
- {SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => ( +
+ {SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => ( ); } diff --git a/frontend/src/components/PostRevisionPanel.tsx b/frontend/src/components/PostRevisionPanel.tsx index 7c4007c..b8f5b47 100644 --- a/frontend/src/components/PostRevisionPanel.tsx +++ b/frontend/src/components/PostRevisionPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight, } from 'lucide-react'; @@ -8,6 +8,7 @@ import { api } from '../api/client'; import type { PostRevision } from '../api/types'; import PostContent from './PostContent'; import { formatDateTime } from '../utils/content'; +import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y'; import { type PostSnapshot, htmlToDiffText, @@ -27,6 +28,12 @@ interface Props { type ViewMode = 'diff' | 'before' | 'after'; +const VIEW_MODES = [ + ['diff', GitCompare, '变更对比'], + ['before', FileText, '编辑前'], + ['after', ArrowRight, '编辑后'], +] as const; + interface RevisionEntry { rev: PostRevision; after: PostSnapshot; @@ -105,6 +112,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, const [loading, setLoading] = useState(false); const [viewMode, setViewMode] = useState('diff'); const [fullscreen, setFullscreen] = useState(false); + const viewTabsRef = useRef(null); + const panelRef = useRef(null); + const closeRef = useRef(null); + + const handleClose = useCallback(() => onClose(), [onClose]); + useOverlayA11y(open, handleClose, panelRef, { initialFocusRef: closeRef }); useEffect(() => { if (!open) { @@ -124,7 +137,6 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, .finally(() => setLoading(false)); }, [open, postId]); - // 阻止背景滚动 useEffect(() => { if (!open) return; const prev = document.body.style.overflow; @@ -166,9 +178,10 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, return (
e.stopPropagation()} role="dialog" @@ -177,7 +190,7 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, >
- +

编辑历史

{selected && ( @@ -186,21 +199,33 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, )}
-
- {([ - ['diff', GitCompare, '变更对比'], - ['before', FileText, '编辑前'], - ['after', ArrowRight, '编辑后'], - ] as const).map(([mode, Icon, label]) => ( +
{ + 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('[role="tab"]')[next]?.focus(); + }); + }} + > + {VIEW_MODES.map(([mode, Icon, label]) => ( ))} @@ -212,10 +237,16 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose, title={fullscreen ? '退出全屏' : '全屏显示'} aria-label={fullscreen ? '退出全屏' : '全屏显示'} > - {fullscreen ? : } + {fullscreen ? : } -
diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index aeb6052..6a52b15 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -6,6 +6,8 @@ interface Props { notifications: Notification[]; online: OnlineStats | null; onPostClick: (id: number) => void; + /** 首次拉取中,避免空态闪烁 */ + loading?: boolean; } function hotRankClass(index: number): string { @@ -15,7 +17,13 @@ function hotRankClass(index: number): string { 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 noticeList = notifications?.slice(0, 6) ?? []; const members = online?.users ?? []; @@ -28,13 +36,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }: 热门帖子
- {hotList.length === 0 ? ( + {loading && hotList.length === 0 ? ( +
加载中…
+ ) : hotList.length === 0 ? (
暂无数据
) : hotList.map((item, i) => ( -
onPostClick(item.id)}> +
+ ))}
@@ -45,13 +60,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }: 最新动态
- {noticeList.length === 0 ? ( + {loading && noticeList.length === 0 ? ( +
加载中…
+ ) : noticeList.length === 0 ? (
暂无动态
) : noticeList.map(item => ( -
onPostClick(item.id)}> +
+ ))}
@@ -66,15 +88,21 @@ export default function RightPanel({ hot, notifications, online, onPostClick }: 会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
- {members.map(u => ( - - {u.avatar - ? - : (u.nickname?.[0] || '?')} - - ))} - {members.length === 0 && ( - 暂无会员在线 + {loading && online == null ? ( + 加载中… + ) : ( + <> + {members.map(u => ( + + {u.avatar + ? + : (u.nickname?.[0] || '?')} + + ))} + {members.length === 0 && ( + 暂无会员在线 + )} + )}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d70de58..ce5e8b8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -56,8 +56,8 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) { +
+ )} + +
); } -/** 管理页通用权限守卫 */ +/** 管理页就绪状态(鉴权由 AdminLayout 负责,此处不再重复跳转) */ export function useAdminGuard() { 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' }; } diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index c20f3f0..18a8434 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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 { 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 { DropdownMenu, DropdownMenuContent, @@ -11,11 +11,13 @@ import { } from '@/components/ui/dropdown-menu'; import { useAuth } from '../hooks/useAuth'; import { useTheme, useMediaQuery } from '../hooks/useTheme'; +import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { api } from '../api/client'; import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types'; import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import RightPanel from '../components/RightPanel'; +import BackToTop from '../components/BackToTop'; import { useForumLimits } from '../hooks/useForumLimits'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; @@ -27,6 +29,7 @@ export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); const { theme, toggle } = useTheme(); const isMobile = useMediaQuery('(max-width: 768px)'); + const hideAside = useMediaQuery('(max-width: 1100px)'); const nav = useNavigate(); const loc = useLocation(); const [params] = useSearchParams(); @@ -34,17 +37,38 @@ export default function MainLayout() { const [boards, setBoards] = useState(() => getCachedBoards()); const [stats, setStats] = useState(() => getCachedStats()); - const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats()); const [hot, setHot] = useState([]); const [notifications, setNotifications] = useState([]); const [online, setOnline] = useState(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 [keyword, setKeyword] = useState(params.get('keyword') || ''); const feedSort = parseFeedSort(params.get('sort')); const { limits: forumLimits } = useForumLimits(); + const asideDrawerRef = useRef(null); + const asideCloseRef = useRef(null); + const boardBarRef = useRef(null); + + const closeAside = useCallback(() => setAsideOpen(false), []); + useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, { + initialFocusRef: asideCloseRef, + }); + useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [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(() => { Promise.all([ @@ -59,7 +83,7 @@ export default function MainLayout() { setCachedStats(next); return next; }).catch(() => null), - ]).finally(() => setLayoutReady(true)); + ]); }, []); const refreshOnline = useCallback(() => { @@ -75,20 +99,45 @@ export default function MainLayout() { useEffect(() => { refreshBoards(); - api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {}); - api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {}); - refreshOnline(); - api.presence().catch(() => {}); - const onlineTimer = setInterval(refreshOnline, 30000); - const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000); const onRefresh = () => refreshBoards(); window.addEventListener('boards-refresh', onRefresh); + return () => 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 () => { + cancelled = true; clearInterval(onlineTimer); - clearInterval(presenceTimer); - window.removeEventListener('boards-refresh', onRefresh); }; - }, [refreshBoards, refreshOnline]); + }, [needAsideData, refreshOnline]); const doSearch = () => { const kw = keyword.trim(); @@ -108,9 +157,34 @@ export default function MainLayout() { nav(`/?keyword=${encodeURIComponent(kw)}`); }; + const openPost = (id: number) => { + setAsideOpen(false); + nav(`/post/${id}`); + }; + const userInitial = user?.nickname?.charAt(0) || '?'; + const isFeedHome = loc.pathname === '/'; 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('[role="tab"]'); + tabs?.[next]?.focus(); + }); + }; + return (
@@ -123,11 +197,12 @@ export default function MainLayout() { {!isCompose && (
- + setKeyword(e.target.value)} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} @@ -150,20 +225,36 @@ export default function MainLayout() { type="button" className="header-compose-btn" onClick={() => user ? nav('/compose') : nav('/login')} + aria-label="发帖" > - + {!isMobile && 发帖} )}
+ {!isCompose && hideAside && ( + + )} + {authLoading ? ( @@ -171,9 +262,9 @@ export default function MainLayout() { ) : user ? ( - @@ -217,25 +308,40 @@ export default function MainLayout() {
- {isMobile && !isCompose && ( -
- + + {boards.map((b, i) => { const themeIdx = getBoardThemeIndex(b); const isActive = mobileActiveBoard === b.id; + const idx = i + 1; return ( - { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }} - >{b.name} + onClick={() => selectBoardChip(b.id)} + >{b.name} ); })}
@@ -247,7 +353,6 @@ export default function MainLayout() { setBoardId, boards, stats, - layoutReady, refreshBoards, isMobile, } satisfies LayoutCtx} /> @@ -260,13 +365,58 @@ export default function MainLayout() { hot={hot} notifications={notifications} online={online} - onPostClick={(id) => nav(`/post/${id}`)} + loading={asideLoading} + onPostClick={openPost} /> )}
+ + {asideOpen && hideAside && !isCompose && ( +
+ +
+
+ +
+ +
+ )} + +
); } @@ -277,7 +427,6 @@ export type LayoutCtx = { setBoardId: (id: number) => void; boards: Board[]; stats: ForumStats | null; - layoutReady: boolean; refreshBoards: () => void; isMobile: boolean; }; diff --git a/frontend/src/pages/BoardsManagePage.tsx b/frontend/src/pages/BoardsManagePage.tsx index fb5c9d9..645ffb2 100644 --- a/frontend/src/pages/BoardsManagePage.tsx +++ b/frontend/src/pages/BoardsManagePage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Plus } from 'lucide-react'; +import { Plus, FolderKanban } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -132,7 +132,7 @@ export default function BoardsManagePage() { return (
-
+

板块管理

创建、编辑或删除论坛板块;可为每个板块自定义图标与色标

@@ -209,6 +209,7 @@ export default function BoardsManagePage() { {boards.length === 0 && (
+

还没有板块,点击右上角创建第一个

)} diff --git a/frontend/src/pages/ComposePage.tsx b/frontend/src/pages/ComposePage.tsx index eb68300..b9c0ccb 100644 --- a/frontend/src/pages/ComposePage.tsx +++ b/frontend/src/pages/ComposePage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; -import { useNavigate, useSearchParams, useParams } from 'react-router-dom'; -import { ArrowLeft, Send, Tag } from 'lucide-react'; +import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom'; +import { ArrowLeft, Send, Pencil } from 'lucide-react'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; import { useAuth } from '../hooks/useAuth'; @@ -10,7 +10,10 @@ import { useForumLimits } from '../hooks/useForumLimits'; import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard'; import ArticleEditor from '../components/ArticleEditor'; import UnsavedChangesDialog from '../components/UnsavedChangesDialog'; +import TagInput, { serializeTags, parseTags } from '../components/TagInput'; import { Spinner } from '@/components/ui/spinner'; +import { getCachedBoards } from '../utils/layoutCache'; +import type { LayoutCtx } from '../layouts/MainLayout'; interface ComposeBaseline { title: string; @@ -19,6 +22,11 @@ interface ComposeBaseline { boardId: string; } +function resolveBoards(ctxBoards?: Board[]): Board[] { + if (ctxBoards && ctxBoards.length > 0) return ctxBoards; + return getCachedBoards(); +} + export default function ComposePage() { const nav = useNavigate(); const { id: editIdParam } = useParams(); @@ -28,14 +36,19 @@ export default function ComposePage() { const defaultBoard = params.get('board') || ''; const { user, loading: authLoading } = useAuth(); const { limits } = useForumLimits(); + const layoutCtx = useOutletContext(); - const [boards, setBoards] = useState([]); + const [boards, setBoards] = useState(() => resolveBoards(layoutCtx?.boards)); const [boardId, setBoardId] = useState(defaultBoard); const [title, setTitle] = useState(''); const [tags, setTags] = useState(''); const [content, setContent] = useState(''); const [publishing, setPublishing] = useState(false); const [loading, setLoading] = useState(isEdit); + /** 新建帖:板块列表是否已就绪(避免请求中误显空态) */ + const [boardsReady, setBoardsReady] = useState( + () => isEdit || resolveBoards(layoutCtx?.boards).length > 0, + ); const [baseline, setBaseline] = useState(null); useEffect(() => { @@ -44,7 +57,11 @@ export default function ComposePage() { if (isEdit) { 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]) => { const list = boardsData.boards ?? []; setBoards(list); @@ -80,11 +97,27 @@ export default function ComposePage() { return; } - api.boards().then(d => { - const list = d.boards ?? []; + const list = resolveBoards(layoutCtx?.boards); + if (list.length > 0) { setBoards(list); - const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : ''); - if (!defaultBoard && list.length > 0) { + setBoardsReady(true); + 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); } setBaseline({ @@ -93,14 +126,16 @@ export default function ComposePage() { content: '', boardId: initialBoardId, }); - }).catch(() => {}); - }, [user, authLoading, nav, defaultBoard, isEdit, editId]); + }).catch(() => { + setBoards([]); + }).finally(() => setBoardsReady(true)); + }, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]); const isDirty = useMemo(() => { if (!baseline) return false; return ( title !== baseline.title - || tags !== baseline.tags + || serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags)) || content !== baseline.content || (!isEdit && boardId !== baseline.boardId) ); @@ -124,7 +159,7 @@ export default function ComposePage() { if (!user) return null; - if (loading) { + if (loading || (!isEdit && !boardsReady)) { return (
@@ -136,7 +171,9 @@ export default function ComposePage() { return (
-
+
+ +

暂无可发帖板块

需要管理员先创建板块后才能发布内容

{user.role === 'admin' ? ( @@ -164,7 +201,7 @@ export default function ComposePage() { const payload = { title: trimmedTitle, content: content.trim(), - tags: tags.trim(), + tags: serializeTags(parseTags(tags)), }; if (isEdit) { await api.updatePost(editId!, payload); @@ -230,16 +267,12 @@ export default function ComposePage() { {currentBoard.name}
)} -
- - setTags(e.target.value)} - maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined} - /> -
+ 0 ? limits.post_tags_max : undefined} + />
diff --git a/frontend/src/pages/FavoritesPage.tsx b/frontend/src/pages/FavoritesPage.tsx index bd10a8b..f42bf1b 100644 --- a/frontend/src/pages/FavoritesPage.tsx +++ b/frontend/src/pages/FavoritesPage.tsx @@ -1,23 +1,19 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Star } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; +import type { PostItem } from '../api/types'; import { useAuth } from '../hooks/useAuth'; -import { formatTime } from '../utils/content'; +import PostListItem from '../components/PostListItem'; interface FavItem { id: number; post_id: number; created_at: string; - post?: { - id: number; - title: string; - board?: { name: string }; - user?: { nickname: string }; - }; + post?: PostItem; } export default function FavoritesPage() { @@ -30,7 +26,7 @@ export default function FavoritesPage() { if (authLoading) return; if (!user) { nav('/login'); return; } 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)) .finally(() => setLoading(false)); }, [user, authLoading, nav]); @@ -51,26 +47,31 @@ export default function FavoritesPage() { {list.length === 0 ? (
+

还没有收藏任何帖子

) : (
{list.map(fav => ( -
nav(`/post/${fav.post_id}`)} - > -
-
{fav.post?.title || '帖子已删除'}
-
- {fav.post?.board?.name && {fav.post.board.name}} - {fav.post?.user?.nickname && {fav.post.user.nickname}} - 收藏于 {formatTime(fav.created_at)} + fav.post ? ( + nav(`/post/${fav.post_id}`)} + /> + ) : ( +
-
+ + ) ))}
)} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index c2c78e1..019df34 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -79,7 +79,7 @@ export default function LoginPage() { -

+

没有账号?注册

diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 3482ad8..b7ba29d 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; 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 { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -46,40 +46,51 @@ export default function PostDetailPage() { useGlobalWheelScroll(pageRef, !loading && !!post); - const fetchComments = useCallback(async () => { - 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); - } - }; + const loadSeq = useRef(0); useEffect(() => { + if (!postId) return; 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]); + // 发评后局部刷新评论列表(不整页重载) + 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 el = document.getElementById(`floor-${floor}`); if (!el) return; @@ -145,7 +156,7 @@ export default function PostDetailPage() { setReplyTo(null); setSubmitCount(c => c + 1); notify.success('评论成功'); - setComments(await fetchComments()); + await reloadComments(); setTimeout(() => jumpToFloor(r.floor), 100); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '评论失败'); @@ -165,6 +176,7 @@ export default function PostDetailPage() { if (loading) return
; if (!post) return (
+

帖子不存在

@@ -226,7 +238,7 @@ export default function PostDetailPage() {
- {post.user?.avatar ? : authorInitial} + {post.user?.avatar ? : authorInitial}
{post.user?.nickname} @@ -318,7 +330,7 @@ export default function PostDetailPage() {
{comments.length === 0 && !replyTo ? (
-
💬
+

暂无评论,来抢沙发吧

) : ( diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index e406bfc..c4e9834 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -207,7 +207,7 @@ export default function ProfilePage() { return (
-
+
-

+

已有账号?登录

diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 260c51e..d7393ab 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -600,6 +600,64 @@ a:hover { text-decoration: underline; } } @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) { .app-frame { border-left: none; border-right: none; } .sidebar { display: none; } @@ -607,8 +665,6 @@ a:hover { text-decoration: underline; } .header-search-wrap { max-width: none; } .header-compose-btn { width: 34px; padding: 0; justify-content: center; } .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 { @@ -623,6 +679,80 @@ a:hover { text-decoration: underline; } 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 { flex: 1; min-height: 0; @@ -679,6 +809,7 @@ a:hover { text-decoration: underline; } .page-inner { padding: 20px 24px; max-width: 720px; } .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-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; border-radius: 16px; font-size: 13px; + font-family: inherit; + color: inherit; border: 1px solid var(--j13-border-light); background: var(--j13-bg-block); 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 { @@ -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--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 样式保持一致 */ .feed-sort-bar { 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 { display: flex; justify-content: space-between; @@ -1112,8 +1160,15 @@ a:hover { text-decoration: underline; } display: flex; align-items: flex-start; gap: 12px; + width: 100%; padding: 12px 20px; + border: none; border-bottom: 1px solid var(--j13-border-light); + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; transition: background 0.15s, box-shadow 0.15s; position: relative; @@ -1136,11 +1191,19 @@ a:hover { text-decoration: underline; } background: var(--j13-bg-block-accent); } -.post-row:hover::before { +.post-row:hover::before, +.post-row:focus-visible::before { 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); } @@ -1161,7 +1224,8 @@ a:hover { text-decoration: underline; } 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); } @@ -1210,7 +1274,8 @@ a:hover { text-decoration: underline; } flex-shrink: 0; } -.post-row:hover .post-stat { +.post-row:hover .post-stat, +.post-row:focus-visible .post-stat { background: var(--j13-green-bg); color: var(--j13-green); } @@ -1445,15 +1510,72 @@ a:hover { text-decoration: underline; } 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 { text-align: center; padding: 48px 24px; 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 { display: flex; @@ -2472,7 +2594,6 @@ a:hover { text-decoration: underline; } 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; } /* 回复栏(旧版保留兼容) */ @@ -2751,7 +2872,16 @@ a:hover { text-decoration: underline; } 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 滚动 */ @@ -3023,10 +3153,16 @@ a.waline-comment-author:hover { color: var(--j13-green); } } .widget-item { + width: 100%; padding: 7px 0; font-size: 13px; + font-family: inherit; + color: inherit; + text-align: left; cursor: pointer; + border: none; border-bottom: 1px solid var(--j13-border-light); + background: transparent; display: flex; align-items: center; gap: 8px; @@ -3036,7 +3172,8 @@ a.waline-comment-author:hover { color: var(--j13-green); } .widget-item:last-child { border-bottom: none; } -.widget-item:hover { +.widget-item:hover, +.widget-item:focus-visible { color: var(--j13-green); background: var(--j13-bg-block-accent); padding-left: 6px; @@ -3045,6 +3182,11 @@ a.waline-comment-author:hover { color: var(--j13-green); } margin-right: -6px; } +.widget-item:focus-visible { + outline: 2px solid var(--j13-green); + outline-offset: 1px; +} + .widget-item-title { flex: 1; overflow: hidden; @@ -3436,7 +3578,6 @@ a.waline-comment-author:hover { color: var(--j13-green); } border-radius: 14px; background: var(--j13-green-bg); color: var(--j13-green); - font-size: 24px; display: flex; align-items: center; justify-content: center; @@ -3583,13 +3724,15 @@ a.waline-comment-author:hover { color: var(--j13-green); } .compose-tags-field { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; - padding: 6px 14px; + padding: 6px 10px 6px 14px; border: 1px solid var(--j13-border); border-radius: 20px; background: var(--j13-bg-surface); min-width: 220px; + max-width: 100%; + cursor: text; transition: border-color 0.15s; } @@ -3597,23 +3740,111 @@ a.waline-comment-author:hover { color: var(--j13-green); } border-color: var(--j13-green); } -.compose-tags-icon { - color: var(--color-text-4); - font-size: 14px; - flex-shrink: 0; +.compose-tags-field--disabled { + opacity: 0.6; + cursor: not-allowed; } -.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; + 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; background: transparent; outline: none; font-size: 13px; + font-family: inherit; 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); } @@ -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-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-head { @@ -4498,6 +4729,83 @@ a.waline-comment-author:hover { color: var(--j13-green); } .admin-settings-bar p { max-width: none; } .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 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); } diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index fe40cd4..330406d 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -91,5 +91,10 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string el.innerHTML = `${VISIBLE_BADGE_HTML}
${innerHtml}
`; }); + 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; } diff --git a/go.mod b/go.mod index b8c8965..e833703 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.26 require ( github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/kardianos/service v1.2.2 golang.org/x/crypto v0.31.0 + gopkg.in/ini.v1 v1.67.3 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) diff --git a/go.sum b/go.sum index 237f5e9..ee81724 100644 --- a/go.sum +++ b/go.sum @@ -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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/seed.mjs b/scripts/seed.mjs index cf34258..9f38e3b 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -386,6 +386,6 @@ function sleep(ms) { main().catch((err) => { console.error('\n种子失败:', err.message); - console.error('请确认服务已启动,例如:.\\dist\\jiang13.exe --port 3000 --data ./data'); + console.error('请确认服务已启动,例如:.\\dist\\jiang13.exe'); process.exit(1); });