支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
# 本地配置(保留 app.ini.example)
|
||||||
|
/app.ini
|
||||||
|
|
||||||
# 前端依赖与构建缓存
|
# 前端依赖与构建缓存
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -51,14 +51,14 @@ tidy:
|
|||||||
|
|
||||||
## 本地运行(仅后端,使用已 embed 的前端)
|
## 本地运行(仅后端,使用已 embed 的前端)
|
||||||
run:
|
run:
|
||||||
$(GO) run $(MAIN_PKG) --port 3000 --data ./data
|
$(GO) run $(MAIN_PKG)
|
||||||
|
|
||||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+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
|
||||||
|
|
||||||
## 清理编译产物
|
## 清理编译产物
|
||||||
|
|||||||
99
README.md
99
README.md
@@ -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 / 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/
|
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
14
app.ini.example
Normal 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 =
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优雅关机
|
// 交互终端或由服务管理器拉起时均走 Run:
|
||||||
go func() {
|
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
|
||||||
sigCh := make(chan os.Signal, 1)
|
if err := svc.Run(); err != nil {
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
log.Fatalf("运行失败: %v", err)
|
||||||
<-sigCh
|
|
||||||
log.Println("收到关机信号,正在优雅关闭...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("HTTP 服务关闭异常: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
|
||||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
|
||||||
log.Printf("数据目录: %s", cfg.DataDir)
|
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("HTTP 服务异常: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("姜十三论坛已安全退出")
|
|
||||||
}
|
}
|
||||||
|
|||||||
158
cmd/jiang13/program.go
Normal file
158
cmd/jiang13/program.go
Normal 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
|
||||||
|
}
|
||||||
179
config/config.go
179
config/config.go
@@ -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
106
config/ini.go
Normal 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
69
config/paths.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
|
||||||
|
func resolveAppPath() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
|
||||||
|
}
|
||||||
|
exe, err = filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
|
||||||
|
}
|
||||||
|
return exe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultWorkPath 类似 Gitea:默认可执行文件所在目录;
|
||||||
|
// go run 时二进制在临时目录,回退为当前工作目录。
|
||||||
|
func defaultWorkPath() (string, error) {
|
||||||
|
exe, err := resolveAppPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
if isEphemeralExeDir(dir) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取当前目录失败: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(wd), nil
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEphemeralExeDir(dir string) bool {
|
||||||
|
lower := strings.ToLower(filepath.Clean(dir))
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
markers := []string{
|
||||||
|
sep + "go-build",
|
||||||
|
sep + "go-run",
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
markers = append(markers, "\\go-build", "\\go-run")
|
||||||
|
}
|
||||||
|
for _, m := range markers {
|
||||||
|
if strings.Contains(lower, strings.ToLower(m)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func absPath(base, p string) (string, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("路径为空")
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return filepath.Clean(p), nil
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Join(base, p))
|
||||||
|
}
|
||||||
125
frontend/src/components/BackToTop.tsx
Normal file
125
frontend/src/components/BackToTop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,16 +88,22 @@ 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">
|
||||||
|
{loading && online == null ? (
|
||||||
|
<span className="widget-empty widget-empty--inline">加载中…</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{members.map(u => (
|
{members.map(u => (
|
||||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||||
{u.avatar
|
{u.avatar
|
||||||
? <img src={u.avatar} alt="" />
|
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
: (u.nickname?.[0] || '?')}
|
: (u.nickname?.[0] || '?')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 && (
|
{members.length === 0 && (
|
||||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
133
frontend/src/components/TagInput.tsx
Normal file
133
frontend/src/components/TagInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
85
frontend/src/hooks/useOverlayA11y.ts
Normal file
85
frontend/src/hooks/useOverlayA11y.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
{!isNarrow && (
|
||||||
<aside className="admin-sidebar">
|
<aside className="admin-sidebar">
|
||||||
{NAV.map(({ to, label, icon: Icon }) => (
|
{navLinks}
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</aside>
|
</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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,17 +267,13 @@ 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} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="添加标签,逗号分隔"
|
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={e => setTags(e.target.value)}
|
onChange={setTags}
|
||||||
|
placeholder="输入标签后回车"
|
||||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="compose-writing">
|
<div className="compose-writing">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<PostListItem
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
|
post={fav.post}
|
||||||
|
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={fav.id}
|
||||||
|
type="button"
|
||||||
className="post-row"
|
className="post-row"
|
||||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||||
>
|
>
|
||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
|
<div className="post-title">帖子已删除</div>
|
||||||
<div className="post-meta">
|
|
||||||
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
|
|
||||||
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
|
|
||||||
<span>收藏于 {formatTime(fav.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
useEffect(() => {
|
||||||
if (!postId) return;
|
if (!postId) return;
|
||||||
|
setReplyTo(null);
|
||||||
|
const seq = ++loadSeq.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setPost(null);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [detail, commList] = await Promise.all([
|
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
|
||||||
|
const myIds = user ? [] : loadMyCommentIds();
|
||||||
|
const [detail, comm] = await Promise.all([
|
||||||
api.post(postId),
|
api.post(postId),
|
||||||
fetchComments(),
|
api.comments(postId, myIds),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
setPost(detail.post);
|
setPost(detail.post);
|
||||||
setLiked(detail.liked);
|
setLiked(detail.liked);
|
||||||
setFavorited(detail.favorited);
|
setFavorited(detail.favorited);
|
||||||
setCanEdit(detail.can_edit ?? false);
|
setCanEdit(detail.can_edit ?? false);
|
||||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||||
setComments(commList);
|
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||||
await refresh();
|
// 会话刷新与正文展示解耦;勿作为 effect 依赖
|
||||||
|
void refresh();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
|
setPost(null);
|
||||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
// 仅 postId 变化时加载;user/refresh 变化不得重跑
|
||||||
useEffect(() => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
|
||||||
setReplyTo(null);
|
|
||||||
load();
|
|
||||||
}, [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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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
2
go.mod
@@ -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
8
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/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=
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user