搭建用户体系
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# 数据库配置
|
||||
DB_USER=stock
|
||||
DB_PASSWORD=stock
|
||||
DB_NAME=stock
|
||||
|
||||
# JWT 密钥(生产环境请务必修改)
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_EXPIRATION_HOURS=168
|
||||
|
||||
# 日志级别
|
||||
RUST_LOG=info
|
||||
|
||||
# 端口
|
||||
BACKEND_PORT=3019
|
||||
FRONTEND_PORT=3018
|
||||
@@ -20,6 +20,14 @@ build/
|
||||
# ===== uv =====
|
||||
.uv/
|
||||
|
||||
# ===== Go =====
|
||||
backend/stock-user-system
|
||||
backend/tmp/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# ===== Node / pnpm =====
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# A股工具 · 用户体系
|
||||
|
||||
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户、游客。前端界面复用 `refer/` 目录下的暗色主题设计语言。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Go 1.25 + Gin + GORM + JWT + bcrypt |
|
||||
| 数据库 | PostgreSQL 18 |
|
||||
| 前端 | React 18 + Vite + Tailwind CSS |
|
||||
| 部署 | Docker Compose |
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 1. 复制环境变量
|
||||
cp .env.example .env
|
||||
|
||||
# 2. 启动服务(首次会编译 Go 后端,可能需要几分钟)
|
||||
docker compose up -d
|
||||
|
||||
# 3. 等待数据库健康检查通过后,访问前端
|
||||
open http://localhost:3018
|
||||
```
|
||||
|
||||
## 默认端口
|
||||
|
||||
| 服务 | 端口 |
|
||||
|---|---|
|
||||
| 前端 | 3018 |
|
||||
| 后端 API | 3019 |
|
||||
| PostgreSQL | 5432 |
|
||||
|
||||
## 创建首个系统管理员
|
||||
|
||||
系统不内置默认账号。启动后,先注册一个普通用户,再通过 SQL 将其提升为系统管理员:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U stock -d stock -c "
|
||||
UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'system_admin')
|
||||
WHERE username = '你的用户名';
|
||||
"
|
||||
```
|
||||
|
||||
## 角色说明
|
||||
|
||||
| 角色 | 权限 |
|
||||
|---|---|
|
||||
| 系统管理员 | 管理管理员、用户、系统配置 |
|
||||
| 管理员 | 管理普通用户 |
|
||||
| 用户 | 访问业务功能、修改个人资料 |
|
||||
| 游客 | 仅查看公开内容,不可操作 |
|
||||
|
||||
## 主要 API
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|---|---|---|---|
|
||||
| GET | /api/public | 公开信息 | 公开 |
|
||||
| POST | /api/auth/register | 注册(默认 user) | 公开 |
|
||||
| POST | /api/auth/login | 登录 | 公开 |
|
||||
| GET | /api/auth/me | 当前用户 | 需登录 |
|
||||
| POST | /api/auth/logout | 登出 | 需登录 |
|
||||
| GET | /api/admin/users | 用户列表 | admin / system_admin |
|
||||
| POST | /api/admin/users | 创建用户 | admin / system_admin |
|
||||
| PUT | /api/admin/users/:id | 更新用户/角色 | admin / system_admin |
|
||||
| DELETE | /api/admin/users/:id | 删除用户 | system_admin |
|
||||
| GET | /api/admin/roles | 角色列表 | admin / system_admin |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
stock/
|
||||
├── backend/ # Go 后端
|
||||
│ ├── cmd/api/main.go
|
||||
│ ├── internal/
|
||||
│ │ ├── config/
|
||||
│ │ ├── db/
|
||||
│ │ ├── handlers/
|
||||
│ │ ├── middleware/
|
||||
│ │ ├── models/
|
||||
│ │ └── routes/
|
||||
│ ├── go.mod
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # React 前端
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 后端本地运行(需先启动 PostgreSQL)
|
||||
cd backend
|
||||
go run ./cmd/api
|
||||
|
||||
# 前端本地运行
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 生产环境请务必修改 `JWT_SECRET`。
|
||||
- 游客通过首页直接访问,无需登录。
|
||||
- 首次启动时 GORM 会自动创建表并插入默认角色。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 多阶段构建:先编译 Go 后端,再使用最小运行时镜像
|
||||
ARG GO_IMAGE=golang:1.25.8-alpine3.23
|
||||
|
||||
FROM ${GO_IMAGE} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
|
||||
|
||||
COPY . ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/stock-user-system ./cmd/api
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/stock-user-system /app/stock-user-system
|
||||
|
||||
ENV DATABASE_URL=""
|
||||
ENV JWT_SECRET=""
|
||||
ENV JWT_EXPIRATION_HOURS="168"
|
||||
ENV PORT="3019"
|
||||
ENV GIN_MODE="release"
|
||||
|
||||
EXPOSE 3019
|
||||
CMD ["/app/stock-user-system"]
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"stock-user-system/internal/config"
|
||||
"stock-user-system/internal/db"
|
||||
"stock-user-system/internal/models"
|
||||
"stock-user-system/internal/routes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
database, err := db.Init(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("init database: %v", err)
|
||||
}
|
||||
|
||||
if err := models.AutoMigrate(database); err != nil {
|
||||
log.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
|
||||
if err := models.SeedRoles(database); err != nil {
|
||||
log.Fatalf("seed roles: %v", err)
|
||||
}
|
||||
|
||||
r := routes.Setup(cfg, database)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
log.Printf("backend listening on %s", addr)
|
||||
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
module stock-user-system
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.31.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
JWTExpirationHours int
|
||||
Port string
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "change-me-in-production"
|
||||
fmt.Fprintln(os.Stderr, "WARNING: JWT_SECRET not set, using default secret")
|
||||
}
|
||||
|
||||
expHours, _ := strconv.Atoi(os.Getenv("JWT_EXPIRATION_HOURS"))
|
||||
if expHours == 0 {
|
||||
expHours = 168 // 7 days
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "3019"
|
||||
}
|
||||
|
||||
allowedOrigins := []string{"*"}
|
||||
if v := os.Getenv("ALLOWED_ORIGINS"); v != "" {
|
||||
allowedOrigins = strings.Split(v, ",")
|
||||
for i := range allowedOrigins {
|
||||
allowedOrigins[i] = strings.TrimSpace(allowedOrigins[i])
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DatabaseURL: databaseURL,
|
||||
JWTSecret: jwtSecret,
|
||||
JWTExpirationHours: expHours,
|
||||
Port: port,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Config) JWTExpiration() time.Duration {
|
||||
return time.Duration(c.JWTExpirationHours) * time.Hour
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Init(databaseURL string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"stock-user-system/internal/config"
|
||||
"stock-user-system/internal/middleware"
|
||||
"stock-user-system/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
DB *gorm.DB
|
||||
CFG *config.Config
|
||||
}
|
||||
|
||||
func allowedRolesForCreation(actor models.RoleName) []models.RoleName {
|
||||
switch actor {
|
||||
case models.RoleSystemAdmin:
|
||||
return []models.RoleName{models.RoleAdmin, models.RoleUser, models.RoleGuest}
|
||||
case models.RoleAdmin:
|
||||
return []models.RoleName{models.RoleUser, models.RoleGuest}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func containsRole(roles []models.RoleName, target models.RoleName) bool {
|
||||
for _, r := range roles {
|
||||
if r == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
var users []models.User
|
||||
if err := h.DB.Preload("Role").Order("created_at DESC").Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]models.PublicUserInfo, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, u.ToPublicInfo())
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
current, _ := middleware.GetCurrentUser(c)
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email *string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
targetRole := models.RoleUser
|
||||
if req.Role != "" {
|
||||
parsed, err := models.ParseRoleName(req.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
|
||||
return
|
||||
}
|
||||
targetRole = parsed
|
||||
}
|
||||
|
||||
if !containsRole(allowedRolesForCreation(current.Role), targetRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateUsername(req.Username); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
var role models.Role
|
||||
if err := h.DB.Where("name = ?", targetRole.String()).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
var email *string
|
||||
if req.Email != nil && *req.Email != "" {
|
||||
e := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
email = &e
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
RoleID: role.ID,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
h.DB.Preload("Role").First(&user, "id = ?", user.ID)
|
||||
c.JSON(http.StatusOK, user.ToPublicInfo())
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
current, _ := middleware.GetCurrentUser(c)
|
||||
userID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var target models.User
|
||||
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !current.Role.CanManage(target.Role.NameEnum()) && current.ID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if req.Email != nil && *req.Email != "" {
|
||||
updates["email"] = strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
}
|
||||
|
||||
if req.Status != "" {
|
||||
updates["status"] = req.Status
|
||||
}
|
||||
|
||||
if req.Role != "" {
|
||||
newRole, err := models.ParseRoleName(req.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
|
||||
return
|
||||
}
|
||||
if !containsRole(allowedRolesForCreation(current.Role), newRole) || !current.Role.CanManage(newRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||
return
|
||||
}
|
||||
var role models.Role
|
||||
if err := h.DB.Where("name = ?", newRole.String()).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
|
||||
return
|
||||
}
|
||||
updates["role_id"] = role.ID
|
||||
}
|
||||
|
||||
if err := h.DB.Model(&target).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
h.DB.Preload("Role").First(&target, "id = ?", userID)
|
||||
c.JSON(http.StatusOK, target.ToPublicInfo())
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
current, _ := middleware.GetCurrentUser(c)
|
||||
userID := c.Param("id")
|
||||
|
||||
if current.ID == userID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能删除自己"})
|
||||
return
|
||||
}
|
||||
|
||||
var target models.User
|
||||
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !current.Role.CanManage(target.Role.NameEnum()) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.Delete(&target).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "用户已删除"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRoles(c *gin.Context) {
|
||||
var roles []models.Role
|
||||
if err := h.DB.Order("id").Find(&roles).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, roles)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"stock-user-system/internal/config"
|
||||
"stock-user-system/internal/middleware"
|
||||
"stock-user-system/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
DB *gorm.DB
|
||||
CFG *config.Config
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email *string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateUsername(req.Username); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
|
||||
var role models.Role
|
||||
if err := h.DB.Where("name = ?", models.RoleUser).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
var email *string
|
||||
if req.Email != nil && *req.Email != "" {
|
||||
e := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
email = &e
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
RoleID: role.ID,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.Preload("Role").First(&user, "id = ?", user.ID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := middleware.GenerateToken(user.ID, models.RoleUser, h.CFG)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": user.ToPublicInfo(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
|
||||
var user models.User
|
||||
if err := h.DB.Preload("Role").Where("LOWER(username) = ?", username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "账号已被禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := middleware.GenerateToken(user.ID, user.Role.NameEnum(), h.CFG)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": user.ToPublicInfo(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
current, ok := middleware.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.DB.Preload("Role").Where("id = ?", current.ID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user.ToPublicInfo())
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "登出成功"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PublicInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "欢迎访问 A股工具公开信息",
|
||||
"guest_allowed": true,
|
||||
})
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if len(username) < 3 || len(username) > 32 {
|
||||
return fmt.Errorf("用户名长度需在 3-32 位之间")
|
||||
}
|
||||
for _, r := range username {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && r != '_' && r != '-' {
|
||||
return fmt.Errorf("用户名只能包含字母、数字、下划线和短横线")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"stock-user-system/internal/config"
|
||||
"stock-user-system/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CurrentUser struct {
|
||||
ID string
|
||||
Username string
|
||||
Role models.RoleName
|
||||
}
|
||||
|
||||
func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
token := ""
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
claims, err := parseToken(token, cfg.JWTSecret)
|
||||
if err == nil {
|
||||
var user models.User
|
||||
if err := db.Preload("Role").Where("id = ? AND status = ?", claims.Subject, "active").First(&user).Error; err == nil {
|
||||
c.Set("currentUser", CurrentUser{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role.NameEnum(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Subject string `json:"sub"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(userID string, role models.RoleName, cfg *config.Config) (string, error) {
|
||||
claims := Claims{
|
||||
Subject: userID,
|
||||
Role: role.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWTExpiration())),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func parseToken(tokenString string, secret string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
func RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("currentUser"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRoles(allowed ...models.RoleName) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
val, exists := c.Get("currentUser")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
current := val.(CurrentUser)
|
||||
for _, role := range allowed {
|
||||
if current.Role == role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func GetCurrentUser(c *gin.Context) (CurrentUser, bool) {
|
||||
val, exists := c.Get("currentUser")
|
||||
if !exists {
|
||||
return CurrentUser{}, false
|
||||
}
|
||||
return val.(CurrentUser), true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RoleName string
|
||||
|
||||
const (
|
||||
RoleSystemAdmin RoleName = "system_admin"
|
||||
RoleAdmin RoleName = "admin"
|
||||
RoleUser RoleName = "user"
|
||||
RoleGuest RoleName = "guest"
|
||||
)
|
||||
|
||||
func (r RoleName) String() string { return string(r) }
|
||||
|
||||
func (r RoleName) Rank() int {
|
||||
switch r {
|
||||
case RoleSystemAdmin:
|
||||
return 3
|
||||
case RoleAdmin:
|
||||
return 2
|
||||
case RoleUser:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r RoleName) CanManage(target RoleName) bool {
|
||||
return r.Rank() > target.Rank()
|
||||
}
|
||||
|
||||
func ParseRoleName(s string) (RoleName, error) {
|
||||
switch s {
|
||||
case "system_admin":
|
||||
return RoleSystemAdmin, nil
|
||||
case "admin":
|
||||
return RoleAdmin, nil
|
||||
case "user":
|
||||
return RoleUser, nil
|
||||
case "guest":
|
||||
return RoleGuest, nil
|
||||
default:
|
||||
return RoleGuest, fmt.Errorf("unknown role: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:32;not null"`
|
||||
Description *string `json:"description"`
|
||||
Permissions string `json:"permissions" gorm:"type:jsonb;default:'[]'"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Users []User `json:"-" gorm:"foreignKey:RoleID"`
|
||||
}
|
||||
|
||||
func (r *Role) NameEnum() RoleName {
|
||||
role, _ := ParseRoleName(r.Name)
|
||||
return role
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
Username string `json:"username" gorm:"uniqueIndex;size:32;not null"`
|
||||
Email *string `json:"email" gorm:"uniqueIndex;size:128"`
|
||||
PasswordHash string `json:"-" gorm:"size:255"`
|
||||
RoleID int32 `json:"role_id" gorm:"not null"`
|
||||
Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID;references:ID"`
|
||||
Status string `json:"status" gorm:"size:16;default:active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PublicUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Role RoleName `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (u *User) ToPublicInfo() PublicUserInfo {
|
||||
return PublicUserInfo{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: u.Role.NameEnum(),
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&Role{}, &User{})
|
||||
}
|
||||
|
||||
func SeedRoles(db *gorm.DB) error {
|
||||
roles := []Role{
|
||||
{Name: string(RoleSystemAdmin), Description: strPtr("系统管理员,可管理管理员与系统配置"), Permissions: `["*"]`},
|
||||
{Name: string(RoleAdmin), Description: strPtr("管理员,可管理普通用户"), Permissions: `["users.read", "users.write", "users.create"]`},
|
||||
{Name: string(RoleUser), Description: strPtr("普通用户,可访问业务功能"), Permissions: `["dashboard.read", "profile.write"]`},
|
||||
{Name: string(RoleGuest), Description: strPtr("游客,仅可查看公开内容"), Permissions: `["public.read"]`},
|
||||
}
|
||||
for _, role := range roles {
|
||||
var existing Role
|
||||
if err := db.Where("name = ?", role.Name).First(&existing).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"stock-user-system/internal/config"
|
||||
"stock-user-system/internal/handlers"
|
||||
"stock-user-system/internal/middleware"
|
||||
"stock-user-system/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
authHandler := &handlers.AuthHandler{DB: db, CFG: cfg}
|
||||
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// CORS
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(middleware.AuthMiddleware(cfg, db))
|
||||
|
||||
// 公开接口
|
||||
r.GET("/api/public", authHandler.PublicInfo)
|
||||
r.POST("/api/auth/register", authHandler.Register)
|
||||
r.POST("/api/auth/login", authHandler.Login)
|
||||
|
||||
// 受保护接口
|
||||
auth := r.Group("/api/auth")
|
||||
auth.Use(middleware.RequireAuth())
|
||||
{
|
||||
auth.GET("/me", authHandler.Me)
|
||||
auth.POST("/logout", authHandler.Logout)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
admin := r.Group("/api/admin")
|
||||
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
|
||||
{
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.POST("/users", adminHandler.CreateUser)
|
||||
admin.PUT("/users/:id", adminHandler.UpdateUser)
|
||||
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
||||
admin.GET("/roles", adminHandler.ListRoles)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.3-alpine3.23
|
||||
container_name: stock_db
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-stock}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-stock}
|
||||
POSTGRES_DB: ${DB_NAME:-stock}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-stock} -d ${DB_NAME:-stock}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- stock_network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: stock_backend
|
||||
environment:
|
||||
DATABASE_URL: postgres://${DB_USER:-stock}:${DB_PASSWORD:-stock}@db:5432/${DB_NAME:-stock}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
|
||||
GIN_MODE: ${GIN_MODE:-release}
|
||||
PORT: 3019
|
||||
ports:
|
||||
- "3019:3019"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- stock_network
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: stock_frontend
|
||||
ports:
|
||||
- "3018:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- stock_network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
stock_network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,18 @@
|
||||
# 前端构建
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
|
||||
FROM ${NODE_IMAGE} AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
# 使用 nginx 提供静态资源
|
||||
FROM nginx:1.29.0-alpine
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0A0A0B" />
|
||||
<title>A股工具</title>
|
||||
</head>
|
||||
<body class="bg-base text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3019;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "stock-user-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.439.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#8B5CF6"/>
|
||||
<text x="50" y="68" font-family="system-ui, sans-serif" font-size="52" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { clearAuth, RoleName } from '@/lib/auth'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
allowedRoles?: RoleName[]
|
||||
requireAuth?: boolean
|
||||
}
|
||||
|
||||
export function AuthGuard({ children, allowedRoles, requireAuth = true }: AuthGuardProps) {
|
||||
const location = useLocation()
|
||||
const [user, setUser] = useState<UserInfo | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
clearAuth()
|
||||
setUser(null)
|
||||
})
|
||||
}, [location.pathname])
|
||||
|
||||
if (user === undefined) {
|
||||
return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (requireAuth && !user) {
|
||||
return <Navigate to="/login" state={{ from: location.pathname }} replace />
|
||||
}
|
||||
|
||||
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
User,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
Menu,
|
||||
} from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { clearAuth, canManageUsers, roleLabel } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
|
||||
const BRAND = '#8B5CF6'
|
||||
|
||||
export function Layout() {
|
||||
const { resolved, setTheme, theme } = useTheme()
|
||||
const isDark = resolved === 'dark'
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
clearAuth()
|
||||
setUser(null)
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {}
|
||||
clearAuth()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
||||
{ to: '/profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'system') {
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
} else {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden w-56 lg:w-60">
|
||||
<div className="px-5 py-5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center text-white font-bold text-sm"
|
||||
style={{ background: BRAND }}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
|
||||
<div>A股</div>
|
||||
<div>工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
量化工具
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 h-px"
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-3 py-3 shrink-0 space-y-2">
|
||||
{user && (
|
||||
<div className="px-3 py-2 rounded-btn bg-elevated/50">
|
||||
<div className="text-sm font-medium text-foreground truncate">{user.username}</div>
|
||||
<div className="text-[10px] text-secondary">{roleLabel(user.role)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-secondary hover:bg-elevated hover:text-foreground transition-colors"
|
||||
>
|
||||
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
{isDark ? '浅色' : '深色'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-danger hover:bg-danger/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col lg:grid lg:grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold">A股工作台</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={cn('fixed inset-0 z-40 lg:static lg:block', mobileOpen ? 'block' : 'hidden')}>
|
||||
<div className="absolute inset-0 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
|
||||
<div className="relative z-10 h-full max-w-[14rem]">{sidebar}</div>
|
||||
</div>
|
||||
<main className="flex-1 min-h-0 overflow-auto scrollbar-gutter-stable p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { Shield, Lock, AlertCircle, Loader2, UserPlus, LogIn } from 'lucide-react'
|
||||
import { api, setToken } from '@/lib/api'
|
||||
import { setStoredUser } from '@/lib/auth'
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
if (mode === 'register' && password.length < 6) {
|
||||
setError('密码长度至少 6 位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res =
|
||||
mode === 'login'
|
||||
? await api.login({ username: username.trim(), password })
|
||||
: await api.register({
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
password,
|
||||
})
|
||||
setToken(res.token)
|
||||
setStoredUser(res.user)
|
||||
onSuccess()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '操作失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div
|
||||
className="rounded-2xl p-4 border border-border"
|
||||
style={{ background: 'linear-gradient(135deg, #8B5CF622, transparent)' }}
|
||||
>
|
||||
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
|
||||
{mode === 'login' ? '登录账号' : '注册账号'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-secondary leading-relaxed">
|
||||
{mode === 'login'
|
||||
? '请输入用户名和密码进入系统。'
|
||||
: '注册后默认获得用户权限。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="邮箱(可选)"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Lock className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : mode === 'login' ? (
|
||||
<LogIn className="h-4 w-4" />
|
||||
) : (
|
||||
<UserPlus className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? '处理中…' : mode === 'login' ? '登录' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login')
|
||||
setError('')
|
||||
}}
|
||||
className="text-xs text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
{mode === 'login' ? '没有账号?立即注册' : '已有账号?直接登录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// 简单内联 motion 组件,避免引入 framer-motion 依赖
|
||||
const motion = {
|
||||
div: ({ children, className, ...props }: any) => {
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el && props.initial) {
|
||||
el.style.opacity = String(props.initial.opacity ?? 1)
|
||||
el.style.transform = `translateY(${props.initial.y ?? 0}px)`
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = `all ${props.transition?.duration ?? 0.3}s ${(props.transition?.ease || [0.16, 1, 0.3, 1]).join(',')}`
|
||||
el.style.opacity = String(props.animate?.opacity ?? 1)
|
||||
el.style.transform = `translateY(${props.animate?.y ?? 0}px)`
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
if (theme !== 'system') return theme
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function updateMetaThemeColor(resolved: ResolvedTheme) {
|
||||
if (typeof document === 'undefined') return
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (!meta) return
|
||||
meta.setAttribute('content', resolved === 'dark' ? '#0A0A0B' : '#FAFAFA')
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
|
||||
} catch {}
|
||||
return 'system'
|
||||
})
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
|
||||
|
||||
useEffect(() => {
|
||||
const nextResolved = resolveTheme(theme)
|
||||
setResolved(nextResolved)
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(nextResolved)
|
||||
try {
|
||||
localStorage.setItem('theme', theme)
|
||||
} catch {}
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
const nextResolved = resolveTheme('system')
|
||||
setResolved(nextResolved)
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(nextResolved)
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}
|
||||
media.addEventListener('change', handler)
|
||||
return () => media.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 暗色为默认(html.dark) / 亮色用 :root 反转 */
|
||||
:root {
|
||||
--base: 0 0% 98%;
|
||||
--surface: 0 0% 100%;
|
||||
--elevated: 240 5% 96%;
|
||||
--border: 240 6% 90%;
|
||||
--fg-primary: 240 6% 10%;
|
||||
--fg-secondary: 240 4% 35%;
|
||||
--fg-muted: 240 5% 65%;
|
||||
--accent: 217 91% 60%;
|
||||
--bull: 4 87% 60%;
|
||||
--bear: 152 67% 45%;
|
||||
--warning: 32 95% 50%;
|
||||
--danger: 4 87% 60%;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--base: 240 5% 5%;
|
||||
--surface: 240 7% 10%;
|
||||
--elevated: 240 9% 14%;
|
||||
--border: 240 5% 22%;
|
||||
--fg-primary: 0 0% 98%;
|
||||
--fg-secondary: 240 5% 78%;
|
||||
--fg-muted: 240 5% 58%;
|
||||
--accent: 217 91% 60%;
|
||||
--bull: 4 87% 60%;
|
||||
--bear: 152 67% 45%;
|
||||
--warning: 32 95% 50%;
|
||||
--danger: 4 87% 60%;
|
||||
}
|
||||
|
||||
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border) / 0.72);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--accent) / 0.75);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.tabular { font-variant-numeric: tabular-nums; }
|
||||
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
|
||||
|
||||
* { border-color: hsl(var(--border)); }
|
||||
|
||||
body {
|
||||
background: hsl(var(--base));
|
||||
color: hsl(var(--fg-primary));
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success?: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
role: 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
description?: string
|
||||
permissions: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('access_token')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data.error || data.message || `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
publicInfo: () =>
|
||||
request<{ message: string; guest_allowed: boolean }>('/api/public'),
|
||||
|
||||
register: (body: { username: string; email?: string; password: string }) =>
|
||||
request<AuthResponse>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
login: (body: { username: string; password: string }) =>
|
||||
request<AuthResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
me: () => request<UserInfo>('/api/auth/me'),
|
||||
|
||||
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listUsers: () => request<UserInfo[]>('/api/admin/users'),
|
||||
|
||||
createUser: (body: { username: string; email?: string; password: string; role?: string }) =>
|
||||
request<UserInfo>('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) =>
|
||||
request<UserInfo>(`/api/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
deleteUser: (id: string) =>
|
||||
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listRoles: () => request<Role[]>('/api/admin/roles'),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { UserInfo } from './api'
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
try {
|
||||
return !!localStorage.getItem('access_token')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredUser(): UserInfo | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('user')
|
||||
return raw ? (JSON.parse(raw) as UserInfo) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredUser(user: UserInfo | null) {
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
} else {
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
export type RoleName = 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
|
||||
const ROLE_RANK: Record<RoleName, number> = {
|
||||
system_admin: 3,
|
||||
admin: 2,
|
||||
user: 1,
|
||||
guest: 0,
|
||||
}
|
||||
|
||||
export function roleRank(role: RoleName): number {
|
||||
return ROLE_RANK[role] ?? 0
|
||||
}
|
||||
|
||||
export function roleLabel(role: RoleName): string {
|
||||
switch (role) {
|
||||
case 'system_admin':
|
||||
return '系统管理员'
|
||||
case 'admin':
|
||||
return '管理员'
|
||||
case 'user':
|
||||
return '用户'
|
||||
case 'guest':
|
||||
return '游客'
|
||||
}
|
||||
}
|
||||
|
||||
export function canAccess(role: RoleName, allowed: RoleName[]): boolean {
|
||||
return allowed.includes(role)
|
||||
}
|
||||
|
||||
export function canManageUsers(role: RoleName): boolean {
|
||||
return role === 'admin' || role === 'system_admin'
|
||||
}
|
||||
|
||||
export function canDeleteUsers(role: RoleName): boolean {
|
||||
return role === 'system_admin'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from './router'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutDashboard, Globe, ShieldCheck } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { getStoredUser, roleLabel } from '@/lib/auth'
|
||||
|
||||
export function Dashboard() {
|
||||
const [publicInfo, setPublicInfo] = useState<{ message: string } | null>(null)
|
||||
const user = getStoredUser()
|
||||
|
||||
useEffect(() => {
|
||||
api.publicInfo().then(setPublicInfo).catch(() => null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
||||
<p className="text-sm text-secondary">欢迎回来,{user?.username || '游客'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card
|
||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||
title="当前身份"
|
||||
value={user ? roleLabel(user.role) : '游客'}
|
||||
desc={user ? `用户:${user.username}` : '未登录,仅可查看公开信息'}
|
||||
/>
|
||||
<Card
|
||||
icon={<Globe className="h-5 w-5 text-bear" />}
|
||||
title="公开信息"
|
||||
value="已接入"
|
||||
desc={publicInfo?.message || '加载中…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">权限说明</h2>
|
||||
<ul className="space-y-2 text-sm text-secondary">
|
||||
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
||||
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
||||
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
||||
<li><span className="text-accent font-medium">游客</span>:仅查看公开内容,不可操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
desc,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string
|
||||
desc: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-4 transition hover:border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-secondary uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="mt-1 text-xs text-muted">{desc}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginForm } from '@/components/LoginForm'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
const BRAND = '#8B5CF6'
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
|
||||
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
|
||||
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 text-foreground">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold tracking-tight">A股工具</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
|
||||
<LoginForm onSuccess={() => navigate('/', { replace: true })} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { User, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { roleLabel } from '@/lib/auth'
|
||||
|
||||
export function Profile() {
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch((err: any) => setError(err?.message || '加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground">个人中心</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">用户名</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.username}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">角色</span>
|
||||
<span className="text-sm font-medium text-foreground">{user ? roleLabel(user.role) : '-'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">邮箱</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.email || '未设置'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">状态</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.status === 'active' ? '启用' : '禁用'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">注册时间</span>
|
||||
<span className="text-sm font-medium text-foreground">{user ? new Date(user.created_at).toLocaleString() : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">关于</h2>
|
||||
<p className="text-sm text-secondary leading-relaxed">
|
||||
本系统为 A 股复盘工具的用户权限管理模块。不同角色拥有不同的操作权限,系统管理员可在「用户管理」中分配角色。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Plus, Trash2, Users as UsersIcon, AlertCircle } from 'lucide-react'
|
||||
import { api, Role, UserInfo } from '@/lib/api'
|
||||
import { canDeleteUsers, roleLabel } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Users() {
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [form, setForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [u, r] = await Promise.all([api.listUsers(), api.listRoles()])
|
||||
setUsers(u)
|
||||
setRoles(r)
|
||||
setError('')
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.createUser({
|
||||
username: form.username,
|
||||
email: form.email || undefined,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
})
|
||||
setForm({ username: '', email: '', password: '', role: 'user' })
|
||||
setFormOpen(false)
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除该用户?')) return
|
||||
try {
|
||||
await api.deleteUser(id)
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (user: UserInfo, status: string) => {
|
||||
try {
|
||||
await api.updateUser(user.id, { status })
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const currentUser = users.find((u) => u.username === JSON.parse(localStorage.getItem('user') || '{}')?.username)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground">用户管理</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen(!formOpen)}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="rounded-card border border-border bg-surface p-4 grid grid-cols-1 md:grid-cols-5 gap-3"
|
||||
>
|
||||
<input
|
||||
required
|
||||
placeholder="用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.name}>{roleLabel(r.name)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="md:col-span-5 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-elevated/50 text-secondary">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">用户名</th>
|
||||
<th className="px-4 py-3 text-left font-medium">角色</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted">暂无用户</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-elevated/30">
|
||||
<td className="px-4 py-3 text-foreground">{u.username}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded', roleBadge(u.role))}>
|
||||
{roleLabel(u.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={u.status}
|
||||
onChange={(e) => handleStatusChange(u, e.target.value)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted text-xs">
|
||||
{new Date(u.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canDeleteUsers(currentUser?.role || 'user') && u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(u.id)}
|
||||
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function roleBadge(role: string) {
|
||||
switch (role) {
|
||||
case 'system_admin':
|
||||
return 'bg-accent/10 text-accent'
|
||||
case 'admin':
|
||||
return 'bg-purple-500/10 text-purple-400'
|
||||
case 'user':
|
||||
return 'bg-bear/10 text-bear'
|
||||
default:
|
||||
return 'bg-muted/10 text-muted'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { AuthGuard } from './components/AuthGuard'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Users } from './pages/Users'
|
||||
import { Profile } from './pages/Profile'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <Login /> },
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
|
||||
<Users />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: (
|
||||
<AuthGuard requireAuth>
|
||||
<Profile />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
])
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
import animate from 'tailwindcss-animate'
|
||||
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
container: { center: true, padding: '1rem' },
|
||||
extend: {
|
||||
colors: {
|
||||
base: 'hsl(var(--base) / <alpha-value>)',
|
||||
surface: 'hsl(var(--surface) / <alpha-value>)',
|
||||
elevated: 'hsl(var(--elevated) / <alpha-value>)',
|
||||
border: 'hsl(var(--border) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--fg-primary) / <alpha-value>)',
|
||||
secondary: 'hsl(var(--fg-secondary) / <alpha-value>)',
|
||||
muted: 'hsl(var(--fg-muted) / <alpha-value>)',
|
||||
accent: 'hsl(var(--accent) / <alpha-value>)',
|
||||
bull: 'hsl(var(--bull) / <alpha-value>)',
|
||||
bear: 'hsl(var(--bear) / <alpha-value>)',
|
||||
warning: 'hsl(var(--warning) / <alpha-value>)',
|
||||
danger: 'hsl(var(--danger) / <alpha-value>)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', '"HarmonyOS Sans SC"', '"PingFang SC"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
card: '8px',
|
||||
btn: '6px',
|
||||
input: '4px',
|
||||
dialog: '12px',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
smooth: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
} satisfies Config
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3019',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user