mirror of
https://github.com/woodchen-ink/aimodels-prices.git
synced 2025-07-18 13:41:59 +08:00
Remove Deno-specific configuration files and migrate to Cloudflare Workers environment
This commit is contained in:
parent
0012d45205
commit
7487aaadda
10
.env.example
10
.env.example
@ -1,10 +0,0 @@
|
|||||||
# Discourse SSO 配置
|
|
||||||
# Discourse 网站地址
|
|
||||||
DISCOURSE_URL=https://q58.pro
|
|
||||||
|
|
||||||
# SSO 密钥 (必需)
|
|
||||||
# 可以使用以下命令生成: openssl rand -hex 32
|
|
||||||
DISCOURSE_SSO_SECRET=your_sso_secret_here
|
|
||||||
|
|
||||||
# 服务器配置
|
|
||||||
PORT=8000
|
|
59
.github/workflows/docker-build.yml
vendored
Normal file
59
.github/workflows/docker-build.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
name: Docker Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
tags: [ 'v*.*.*' ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/aimodels-prices
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 设置 QEMU 以支持多架构构建
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
# 设置 Docker Buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# 登录到 Docker Hub
|
||||||
|
- name: Log into Docker Hub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
# 提取版本信息
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||||
|
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "VERSION=latest" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建并推送 Docker 镜像
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,6 +4,7 @@ kaifa.md
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
wrangler.toml
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# 第一阶段:构建后端
|
||||||
|
FROM golang:1.21-alpine AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制后端源代码
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# 编译后端
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
|
||||||
|
|
||||||
|
# 第二阶段:构建前端
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 复制前端源代码
|
||||||
|
COPY frontend/ .
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 第三阶段:最终镜像
|
||||||
|
FROM alpine:3.18
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 nginx
|
||||||
|
RUN apk add --no-cache nginx
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# 复制后端二进制文件
|
||||||
|
COPY --from=backend-builder /app/backend/main ./
|
||||||
|
COPY backend/config/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# 复制前端构建产物
|
||||||
|
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/static
|
||||||
|
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
|
||||||
|
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend
|
||||||
|
|
||||||
|
# 复制启动脚本
|
||||||
|
COPY scripts/start.sh ./
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
CMD ["./start.sh"]
|
47
backend/config/config.go
Normal file
47
backend/config/config.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBPath string
|
||||||
|
ServerPort string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
// 确保数据目录存在
|
||||||
|
dbDir := "./data"
|
||||||
|
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 data 目录加载 .env 文件
|
||||||
|
envPath := filepath.Join(dbDir, ".env")
|
||||||
|
if err := godotenv.Load(envPath); err != nil {
|
||||||
|
fmt.Printf("Warning: .env file not found in data directory: %v\n", err)
|
||||||
|
// 如果 data/.env 不存在,尝试加载项目根目录的 .env
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
fmt.Printf("Warning: .env file not found in root directory: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
DBPath: filepath.Join(dbDir, "aimodels.db"),
|
||||||
|
ServerPort: getEnv("PORT", "8080"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
67
backend/database/db.go
Normal file
67
backend/database/db.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"aimodels-prices/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB 是数据库连接的全局实例
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
// InitDB 初始化数据库连接
|
||||||
|
func InitDB(dbPath string) error {
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
if err = DB.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置连接池参数
|
||||||
|
DB.SetMaxOpenConns(10)
|
||||||
|
DB.SetMaxIdleConns(5)
|
||||||
|
|
||||||
|
// 创建表结构
|
||||||
|
if err = createTables(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTables 创建数据库表
|
||||||
|
func createTables() error {
|
||||||
|
// 创建用户表
|
||||||
|
if _, err := DB.Exec(models.CreateUserTableSQL()); err != nil {
|
||||||
|
log.Printf("Failed to create user table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话表
|
||||||
|
if _, err := DB.Exec(models.CreateSessionTableSQL()); err != nil {
|
||||||
|
log.Printf("Failed to create session table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建供应商表
|
||||||
|
if _, err := DB.Exec(models.CreateProviderTableSQL()); err != nil {
|
||||||
|
log.Printf("Failed to create provider table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建价格表
|
||||||
|
if _, err := DB.Exec(models.CreatePriceTableSQL()); err != nil {
|
||||||
|
log.Printf("Failed to create price table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
51
backend/go.mod
Normal file
51
backend/go.mod
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
module aimodels-prices
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
modernc.org/sqlite v1.28.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // 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.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // 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.0.8 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.14.0 // indirect
|
||||||
|
golang.org/x/mod v0.8.0 // indirect
|
||||||
|
golang.org/x/net v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.6.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
|
modernc.org/libc v1.29.0 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.7.2 // indirect
|
||||||
|
modernc.org/opt v0.1.3 // indirect
|
||||||
|
modernc.org/strutil v1.1.3 // indirect
|
||||||
|
modernc.org/token v1.0.1 // indirect
|
||||||
|
)
|
134
backend/go.sum
Normal file
134
backend/go.sum
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
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.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
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.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
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/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
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.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
|
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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/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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||||
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
|
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/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=
|
||||||
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||||
|
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||||
|
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||||
|
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
|
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||||
|
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||||
|
modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs=
|
||||||
|
modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||||
|
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||||
|
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||||
|
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||||
|
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||||
|
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||||
|
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
291
backend/handlers/auth.go
Normal file
291
backend/handlers/auth.go
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateSessionID() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAuthStatus(c *gin.Context) {
|
||||||
|
cookie, err := c.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var session models.Session
|
||||||
|
err = db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
|
||||||
|
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := session.GetUser(db)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user", user)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Login(c *gin.Context) {
|
||||||
|
// 开发环境下使用测试账号
|
||||||
|
if gin.Mode() != gin.ReleaseMode {
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
|
||||||
|
// 创建测试用户(如果不存在)
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM user WHERE username = 'admin'").Scan(&count)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
_, err = db.Exec("INSERT INTO user (username, email, role) VALUES (?, ?, ?)",
|
||||||
|
"admin", "admin@test.com", "admin")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create test user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
var userID uint
|
||||||
|
err = db.QueryRow("SELECT id FROM user WHERE username = 'admin'").Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
sessionID := generateSessionID()
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
_, err = db.Exec("INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
sessionID, userID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置cookie
|
||||||
|
c.SetCookie("session", sessionID, int(24*time.Hour.Seconds()), "/", "", false, true)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Logged in successfully"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境使用 Discourse SSO
|
||||||
|
discourseURL := os.Getenv("DISCOURSE_URL")
|
||||||
|
if discourseURL == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Discourse URL not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机 nonce
|
||||||
|
nonce := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate nonce"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonceStr := hex.EncodeToString(nonce)
|
||||||
|
|
||||||
|
// 构建 payload
|
||||||
|
payload := url.Values{}
|
||||||
|
payload.Set("nonce", nonceStr)
|
||||||
|
payload.Set("return_sso_url", fmt.Sprintf("%s/api/auth/callback", c.Request.Host))
|
||||||
|
|
||||||
|
// Base64 编码
|
||||||
|
payloadStr := base64.StdEncoding.EncodeToString([]byte(payload.Encode()))
|
||||||
|
|
||||||
|
// 计算签名
|
||||||
|
ssoSecret := os.Getenv("DISCOURSE_SSO_SECRET")
|
||||||
|
if ssoSecret == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "SSO secret not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(ssoSecret))
|
||||||
|
h.Write([]byte(payloadStr))
|
||||||
|
sig := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
// 构建重定向 URL
|
||||||
|
redirectURL := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
|
||||||
|
discourseURL, url.QueryEscape(payloadStr), sig)
|
||||||
|
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Logout(c *gin.Context) {
|
||||||
|
cookie, err := c.Cookie("session")
|
||||||
|
if err == nil {
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
db.Exec("DELETE FROM session WHERE id = ?", cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie("session", "", -1, "/", "", false, true)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUser(c *gin.Context) {
|
||||||
|
cookie, err := c.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var session models.Session
|
||||||
|
if err := db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
|
||||||
|
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := session.GetUser(db)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthCallback(c *gin.Context) {
|
||||||
|
sso := c.Query("sso")
|
||||||
|
sig := c.Query("sig")
|
||||||
|
|
||||||
|
if sso == "" || sig == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing parameters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 SSO 密钥
|
||||||
|
ssoSecret := os.Getenv("DISCOURSE_SSO_SECRET")
|
||||||
|
if ssoSecret == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "SSO secret not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
h := hmac.New(sha256.New, []byte(ssoSecret))
|
||||||
|
h.Write([]byte(sso))
|
||||||
|
computedSig := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if computedSig != sig {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid signature"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码 SSO payload
|
||||||
|
payload, err := base64.StdEncoding.DecodeString(sso)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid SSO payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 payload
|
||||||
|
values, err := url.ParseQuery(string(payload))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
username := values.Get("username")
|
||||||
|
email := values.Get("email")
|
||||||
|
groups := values.Get("groups")
|
||||||
|
admin := values.Get("admin") // Discourse 管理员标志
|
||||||
|
moderator := values.Get("moderator") // Discourse 版主标志
|
||||||
|
if username == "" || email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing user information"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断用户角色
|
||||||
|
role := "user"
|
||||||
|
// 如果是管理员、版主或属于 admins 组,都赋予管理权限
|
||||||
|
if admin == "true" || moderator == "true" || (groups != "" && strings.Contains(groups, "admins")) {
|
||||||
|
role = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
var user models.User
|
||||||
|
err = db.QueryRow("SELECT id, username, email, role FROM user WHERE email = ?", email).Scan(
|
||||||
|
&user.ID, &user.Username, &user.Email, &user.Role)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// 创建新用户
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO user (username, email, role)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
username, email, role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := result.LastInsertId()
|
||||||
|
user = models.User{
|
||||||
|
ID: uint(userID),
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// 更新现有用户的角色(如果需要)
|
||||||
|
if user.Role != role {
|
||||||
|
_, err = db.Exec("UPDATE user SET role = ? WHERE id = ?", role, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.Role = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
sessionID := generateSessionID()
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
_, err = db.Exec("INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
sessionID, user.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 cookie
|
||||||
|
c.SetCookie("session", sessionID, int(24*time.Hour.Seconds()), "/", "", false, true)
|
||||||
|
|
||||||
|
// 重定向到前端
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/")
|
||||||
|
}
|
274
backend/handlers/prices.go
Normal file
274
backend/handlers/prices.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetPrices(c *gin.Context) {
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, model, billing_type, channel_type, currency, input_price, output_price,
|
||||||
|
price_source, status, created_at, updated_at, created_by,
|
||||||
|
temp_model, temp_billing_type, temp_channel_type, temp_currency,
|
||||||
|
temp_input_price, temp_output_price, temp_price_source, updated_by
|
||||||
|
FROM price ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch prices"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var prices []models.Price
|
||||||
|
for rows.Next() {
|
||||||
|
var price models.Price
|
||||||
|
if err := rows.Scan(
|
||||||
|
&price.ID, &price.Model, &price.BillingType, &price.ChannelType, &price.Currency,
|
||||||
|
&price.InputPrice, &price.OutputPrice, &price.PriceSource, &price.Status,
|
||||||
|
&price.CreatedAt, &price.UpdatedAt, &price.CreatedBy,
|
||||||
|
&price.TempModel, &price.TempBillingType, &price.TempChannelType, &price.TempCurrency,
|
||||||
|
&price.TempInputPrice, &price.TempOutputPrice, &price.TempPriceSource, &price.UpdatedBy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan price"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prices = append(prices, price)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePrice(c *gin.Context) {
|
||||||
|
var price models.Price
|
||||||
|
if err := c.ShouldBindJSON(&price); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证供应商ID是否存在
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var providerExists bool
|
||||||
|
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM provider WHERE id = ?)", price.ChannelType).Scan(&providerExists)
|
||||||
|
if err != nil || !providerExists {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO price (model, billing_type, channel_type, currency, input_price, output_price,
|
||||||
|
price_source, status, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
|
||||||
|
price.Model, price.BillingType, price.ChannelType, price.Currency,
|
||||||
|
price.InputPrice, price.OutputPrice, price.PriceSource, price.CreatedBy,
|
||||||
|
now, now)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create price"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
price.ID = uint(id)
|
||||||
|
price.Status = "pending"
|
||||||
|
price.CreatedAt = now
|
||||||
|
price.UpdatedAt = now
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePriceStatus(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var input struct {
|
||||||
|
Status string `json:"status" binding:"required,oneof=approved rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if input.Status == "approved" {
|
||||||
|
// 如果是批准,将临时字段的值更新到正式字段
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE price
|
||||||
|
SET model = COALESCE(temp_model, model),
|
||||||
|
billing_type = COALESCE(temp_billing_type, billing_type),
|
||||||
|
channel_type = COALESCE(temp_channel_type, channel_type),
|
||||||
|
currency = COALESCE(temp_currency, currency),
|
||||||
|
input_price = COALESCE(temp_input_price, input_price),
|
||||||
|
output_price = COALESCE(temp_output_price, output_price),
|
||||||
|
price_source = COALESCE(temp_price_source, price_source),
|
||||||
|
status = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
temp_model = NULL,
|
||||||
|
temp_billing_type = NULL,
|
||||||
|
temp_channel_type = NULL,
|
||||||
|
temp_currency = NULL,
|
||||||
|
temp_input_price = NULL,
|
||||||
|
temp_output_price = NULL,
|
||||||
|
temp_price_source = NULL,
|
||||||
|
updated_by = NULL
|
||||||
|
WHERE id = ?`, input.Status, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果是拒绝,清除临时字段
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE price
|
||||||
|
SET status = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
temp_model = NULL,
|
||||||
|
temp_billing_type = NULL,
|
||||||
|
temp_channel_type = NULL,
|
||||||
|
temp_currency = NULL,
|
||||||
|
temp_input_price = NULL,
|
||||||
|
temp_output_price = NULL,
|
||||||
|
temp_price_source = NULL,
|
||||||
|
updated_by = NULL
|
||||||
|
WHERE id = ?`, input.Status, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Status updated successfully",
|
||||||
|
"status": input.Status,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePrice 更新价格
|
||||||
|
func UpdatePrice(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var price models.Price
|
||||||
|
if err := c.ShouldBindJSON(&price); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证供应商ID是否存在
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var providerExists bool
|
||||||
|
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM provider WHERE id = ?)", price.ChannelType).Scan(&providerExists)
|
||||||
|
if err != nil || !providerExists {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUser := user.(*models.User)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// 将新的价格信息存储到临时字段
|
||||||
|
_, err = db.Exec(`
|
||||||
|
UPDATE price
|
||||||
|
SET temp_model = ?, temp_billing_type = ?, temp_channel_type = ?, temp_currency = ?,
|
||||||
|
temp_input_price = ?, temp_output_price = ?, temp_price_source = ?,
|
||||||
|
updated_by = ?, updated_at = ?, status = 'pending'
|
||||||
|
WHERE id = ?`,
|
||||||
|
price.Model, price.BillingType, price.ChannelType, price.Currency,
|
||||||
|
price.InputPrice, price.OutputPrice, price.PriceSource,
|
||||||
|
currentUser.Username, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取更新后的价格信息
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT id, model, billing_type, channel_type, currency, input_price, output_price,
|
||||||
|
price_source, status, created_at, updated_at, created_by,
|
||||||
|
temp_model, temp_billing_type, temp_channel_type, temp_currency,
|
||||||
|
temp_input_price, temp_output_price, temp_price_source, updated_by
|
||||||
|
FROM price WHERE id = ?`, id).Scan(
|
||||||
|
&price.ID, &price.Model, &price.BillingType, &price.ChannelType, &price.Currency,
|
||||||
|
&price.InputPrice, &price.OutputPrice, &price.PriceSource, &price.Status,
|
||||||
|
&price.CreatedAt, &price.UpdatedAt, &price.CreatedBy,
|
||||||
|
&price.TempModel, &price.TempBillingType, &price.TempChannelType, &price.TempCurrency,
|
||||||
|
&price.TempInputPrice, &price.TempOutputPrice, &price.TempPriceSource, &price.UpdatedBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated price"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrice 删除价格
|
||||||
|
func DeletePrice(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
|
||||||
|
_, err := db.Exec("DELETE FROM price WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete price"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Price deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceRate 价格倍率结构
|
||||||
|
type PriceRate struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ChannelType uint `json:"channel_type"`
|
||||||
|
Input float64 `json:"input"`
|
||||||
|
Output float64 `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceRates 获取价格倍率
|
||||||
|
func GetPriceRates(c *gin.Context) {
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT model, billing_type, channel_type,
|
||||||
|
CASE
|
||||||
|
WHEN currency = 'USD' THEN input_price / 2
|
||||||
|
ELSE input_price / 14
|
||||||
|
END as input_rate,
|
||||||
|
CASE
|
||||||
|
WHEN currency = 'USD' THEN output_price / 2
|
||||||
|
ELSE output_price / 14
|
||||||
|
END as output_rate
|
||||||
|
FROM price
|
||||||
|
WHERE status = 'approved'
|
||||||
|
ORDER BY model, channel_type`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch price rates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var rates []PriceRate
|
||||||
|
for rows.Next() {
|
||||||
|
var rate PriceRate
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rate.Model,
|
||||||
|
&rate.Type,
|
||||||
|
&rate.ChannelType,
|
||||||
|
&rate.Input,
|
||||||
|
&rate.Output); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan price rate"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rates = append(rates, rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, rates)
|
||||||
|
}
|
188
backend/handlers/providers.go
Normal file
188
backend/handlers/providers.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetProviders 获取所有供应商
|
||||||
|
func GetProviders(c *gin.Context) {
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, name, icon, created_at, updated_at, created_by
|
||||||
|
FROM provider ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch providers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var providers []models.Provider
|
||||||
|
for rows.Next() {
|
||||||
|
var provider models.Provider
|
||||||
|
if err := rows.Scan(
|
||||||
|
&provider.ID, &provider.Name, &provider.Icon,
|
||||||
|
&provider.CreatedAt, &provider.UpdatedAt, &provider.CreatedBy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providers = append(providers, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider 创建供应商
|
||||||
|
func CreateProvider(c *gin.Context) {
|
||||||
|
var provider models.Provider
|
||||||
|
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查ID是否已存在
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var existingID int
|
||||||
|
err := db.QueryRow("SELECT id FROM provider WHERE id = ?", provider.ID).Scan(&existingID)
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUser := user.(*models.User)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO provider (id, name, icon, created_at, updated_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
provider.ID, provider.Name, provider.Icon, now, now, currentUser.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.CreatedAt = now
|
||||||
|
provider.UpdatedAt = now
|
||||||
|
provider.CreatedBy = currentUser.Username
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProvider 更新供应商
|
||||||
|
func UpdateProvider(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider models.Provider
|
||||||
|
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
now := time.Now()
|
||||||
|
_, err = db.Exec(`
|
||||||
|
UPDATE provider
|
||||||
|
SET name = ?, icon = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
provider.Name, provider.Icon, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取更新后的供应商信息
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT id, name, icon, created_at, updated_at, created_by
|
||||||
|
FROM provider WHERE id = ?`, id).Scan(
|
||||||
|
&provider.ID, &provider.Name, &provider.Icon,
|
||||||
|
&provider.CreatedAt, &provider.UpdatedAt, &provider.CreatedBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProviderStatus 更新供应商状态
|
||||||
|
func UpdateProviderStatus(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var input struct {
|
||||||
|
Status string `json:"status" binding:"required,oneof=approved rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if input.Status == "approved" {
|
||||||
|
// 如果是批准,将临时字段的值更新到正式字段
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE provider
|
||||||
|
SET name = COALESCE(temp_name, name),
|
||||||
|
icon = COALESCE(temp_icon, icon),
|
||||||
|
status = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
temp_name = NULL,
|
||||||
|
temp_icon = NULL,
|
||||||
|
updated_by = NULL
|
||||||
|
WHERE id = ?`, input.Status, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果是拒绝,清除临时字段
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE provider
|
||||||
|
SET status = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
temp_name = NULL,
|
||||||
|
temp_icon = NULL,
|
||||||
|
updated_by = NULL
|
||||||
|
WHERE id = ?`, input.Status, now, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Status updated successfully",
|
||||||
|
"status": input.Status,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider 删除供应商
|
||||||
|
func DeleteProvider(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
_, err := db.Exec("DELETE FROM provider WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted successfully"})
|
||||||
|
}
|
98
backend/main.go
Normal file
98
backend/main.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/config"
|
||||||
|
"aimodels-prices/database"
|
||||||
|
"aimodels-prices/handlers"
|
||||||
|
"aimodels-prices/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 加载配置
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
if err := database.InitDB(cfg.DBPath); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.DB.Close()
|
||||||
|
|
||||||
|
// 设置gin模式
|
||||||
|
if gin.Mode() == gin.ReleaseMode {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 注入数据库
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Set("db", database.DB)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// CORS中间件
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
origin := c.Request.Header.Get("Origin")
|
||||||
|
if origin != "" {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// API路由组
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
// 价格相关路由
|
||||||
|
prices := api.Group("/prices")
|
||||||
|
{
|
||||||
|
prices.GET("", handlers.GetPrices)
|
||||||
|
prices.GET("/rates", handlers.GetPriceRates)
|
||||||
|
prices.POST("", middleware.AuthRequired(), handlers.CreatePrice)
|
||||||
|
prices.PUT("/:id", middleware.AuthRequired(), handlers.UpdatePrice)
|
||||||
|
prices.DELETE("/:id", middleware.AuthRequired(), handlers.DeletePrice)
|
||||||
|
prices.PUT("/:id/status", middleware.AuthRequired(), middleware.AdminRequired(), handlers.UpdatePriceStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供应商相关路由
|
||||||
|
providers := api.Group("/providers")
|
||||||
|
{
|
||||||
|
providers.GET("", handlers.GetProviders)
|
||||||
|
providers.POST("", middleware.AuthRequired(), handlers.CreateProvider)
|
||||||
|
providers.PUT("/:id", middleware.AuthRequired(), middleware.AdminRequired(), handlers.UpdateProvider)
|
||||||
|
providers.DELETE("/:id", middleware.AuthRequired(), middleware.AdminRequired(), handlers.DeleteProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证相关路由
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.GET("/status", handlers.GetAuthStatus)
|
||||||
|
auth.POST("/login", handlers.Login)
|
||||||
|
auth.POST("/logout", handlers.Logout)
|
||||||
|
auth.GET("/user", handlers.GetUser)
|
||||||
|
auth.GET("/callback", handlers.AuthCallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
addr := fmt.Sprintf(":%s", cfg.ServerPort)
|
||||||
|
if err := r.Run(addr); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
97
backend/middleware/auth.go
Normal file
97
backend/middleware/auth.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
cookie, err := c.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*sql.DB)
|
||||||
|
var session models.Session
|
||||||
|
err = db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
|
||||||
|
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := session.GetUser(db)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user", user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if u, ok := user.(*models.User); !ok || u.Role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
_, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if u, ok := user.(*models.User); !ok || u.Role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
14
backend/middleware/db.go
Normal file
14
backend/middleware/db.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aimodels-prices/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Database() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Set("db", database.DB)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
57
backend/models/price.go
Normal file
57
backend/models/price.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Price struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
BillingType string `json:"billing_type"` // tokens or times
|
||||||
|
ChannelType string `json:"channel_type"`
|
||||||
|
Currency string `json:"currency"` // USD or CNY
|
||||||
|
InputPrice float64 `json:"input_price"`
|
||||||
|
OutputPrice float64 `json:"output_price"`
|
||||||
|
PriceSource string `json:"price_source"`
|
||||||
|
Status string `json:"status"` // pending, approved, rejected
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
// 临时字段,用于存储待审核的更新
|
||||||
|
TempModel *string `json:"temp_model,omitempty"`
|
||||||
|
TempBillingType *string `json:"temp_billing_type,omitempty"`
|
||||||
|
TempChannelType *string `json:"temp_channel_type,omitempty"`
|
||||||
|
TempCurrency *string `json:"temp_currency,omitempty"`
|
||||||
|
TempInputPrice *float64 `json:"temp_input_price,omitempty"`
|
||||||
|
TempOutputPrice *float64 `json:"temp_output_price,omitempty"`
|
||||||
|
TempPriceSource *string `json:"temp_price_source,omitempty"`
|
||||||
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePriceTableSQL 返回创建价格表的 SQL
|
||||||
|
func CreatePriceTableSQL() string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE IF NOT EXISTS price (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
billing_type TEXT NOT NULL,
|
||||||
|
channel_type TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
input_price REAL NOT NULL,
|
||||||
|
output_price REAL NOT NULL,
|
||||||
|
price_source TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
temp_model TEXT,
|
||||||
|
temp_billing_type TEXT,
|
||||||
|
temp_channel_type TEXT,
|
||||||
|
temp_currency TEXT,
|
||||||
|
temp_input_price REAL,
|
||||||
|
temp_output_price REAL,
|
||||||
|
temp_price_source TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
FOREIGN KEY (channel_type) REFERENCES provider(id)
|
||||||
|
)`
|
||||||
|
}
|
25
backend/models/provider.go
Normal file
25
backend/models/provider.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProviderTableSQL 返回创建供应商表的 SQL
|
||||||
|
func CreateProviderTableSQL() string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE IF NOT EXISTS provider (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL
|
||||||
|
)`
|
||||||
|
}
|
64
backend/models/user.go
Normal file
64
backend/models/user.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"` // admin or user
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTableSQL 返回创建用户表的 SQL
|
||||||
|
func CreateUserTableSQL() string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSessionTableSQL 返回创建会话表的 SQL
|
||||||
|
func CreateSessionTableSQL() string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE IF NOT EXISTS session (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user(id)
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser 获取会话关联的用户
|
||||||
|
func (s *Session) GetUser(db *sql.DB) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := db.QueryRow("SELECT id, username, email, role, created_at, updated_at, deleted_at FROM user WHERE id = ?", s.UserID).Scan(
|
||||||
|
&user.ID, &user.Username, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.DeletedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
37
backend/router/router.go
Normal file
37
backend/router/router.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"aimodels-prices/handlers"
|
||||||
|
"aimodels-prices/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRouter 设置路由
|
||||||
|
func SetupRouter() *gin.Engine {
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 添加数据库中间件
|
||||||
|
r.Use(middleware.Database())
|
||||||
|
|
||||||
|
// 认证相关路由
|
||||||
|
auth := r.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.GET("/status", handlers.GetAuthStatus)
|
||||||
|
auth.POST("/login", handlers.Login)
|
||||||
|
auth.POST("/logout", handlers.Logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供应商相关路由
|
||||||
|
providers := r.Group("/providers")
|
||||||
|
{
|
||||||
|
providers.GET("", handlers.GetProviders)
|
||||||
|
providers.Use(middleware.RequireAuth())
|
||||||
|
providers.Use(middleware.RequireAdmin())
|
||||||
|
providers.POST("", handlers.CreateProvider)
|
||||||
|
providers.PUT("/:id", handlers.UpdateProvider)
|
||||||
|
providers.DELETE("/:id", handlers.DeleteProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
11
deno.json
11
deno.json
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"],
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"importMap": "./import_map.json",
|
|
||||||
"tasks": {
|
|
||||||
"start": "deno run --allow-net --allow-read --allow-env main.ts"
|
|
||||||
}
|
|
||||||
}
|
|
13
deno.jsonc
13
deno.jsonc
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"lib": ["dom", "dom.iterable", "deno.ns"],
|
|
||||||
"strict": true,
|
|
||||||
"types": ["https://deno.land/x/types/deno.ns.d.ts"]
|
|
||||||
},
|
|
||||||
"importMap": "import_map.json",
|
|
||||||
"tasks": {
|
|
||||||
"start": "deno run --allow-net --allow-read --allow-env main.ts",
|
|
||||||
"dev": "deno run --watch --allow-net --allow-read --allow-env main.ts"
|
|
||||||
}
|
|
||||||
}
|
|
3
deps.ts
3
deps.ts
@ -1,3 +0,0 @@
|
|||||||
export { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
|
||||||
export { crypto } from "https://deno.land/std@0.208.0/crypto/mod.ts";
|
|
||||||
export { decode as base64Decode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
|
|
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 170 KiB |
26
frontend/index.html
Normal file
26
frontend/index.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
|
||||||
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="专业的AI模型价格管理系统,支持多供应商、多币种的价格管理,提供标准的API接口。">
|
||||||
|
<meta name="keywords" content="AI模型,价格管理,API接口,OpenAI,Azure,Anthropic">
|
||||||
|
<meta name="author" content="AI Models Prices Team">
|
||||||
|
<title>AI模型价格</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1482
frontend/package-lock.json
generated
Normal file
1482
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "aimodels-prices-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"element-plus": "^2.5.3",
|
||||||
|
"vue": "^3.4.15",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"vite": "^5.0.12"
|
||||||
|
}
|
||||||
|
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 170 KiB |
171
frontend/src/App.vue
Normal file
171
frontend/src/App.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<el-container>
|
||||||
|
<el-header height="60px">
|
||||||
|
<div class="nav-container">
|
||||||
|
<div class="nav-left">
|
||||||
|
<router-link to="/" class="logo">
|
||||||
|
AI模型价格
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<el-button @click="$router.push('/prices')" :type="$route.path === '/prices' ? 'primary' : ''">价格列表</el-button>
|
||||||
|
<el-button @click="$router.push('/providers')" :type="$route.path === '/providers' ? 'primary' : ''">供应商列表</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-buttons">
|
||||||
|
<template v-if="globalUser">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ globalUser.username }}
|
||||||
|
<el-tag v-if="globalUser.role === 'admin'" size="small" type="success">管理员</el-tag>
|
||||||
|
</span>
|
||||||
|
<el-button @click="handleLogout">退出</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-else type="primary" @click="$router.push('/login')">登录</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<el-main>
|
||||||
|
<div class="content-container">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<component :is="Component" :user="globalUser" />
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</el-main>
|
||||||
|
|
||||||
|
<el-footer height="60px">
|
||||||
|
<div class="footer-content">
|
||||||
|
<p>© 2024 AI模型价格 | <a href="https://github.com" target="_blank">GitHub</a></p>
|
||||||
|
</div>
|
||||||
|
</el-footer>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, provide } from 'vue'
|
||||||
|
import { User } from '@element-plus/icons-vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const globalUser = ref(null)
|
||||||
|
|
||||||
|
const updateGlobalUser = (user) => {
|
||||||
|
globalUser.value = user
|
||||||
|
}
|
||||||
|
|
||||||
|
provide('updateGlobalUser', updateGlobalUser)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get('/api/auth/status')
|
||||||
|
globalUser.value = data.user
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get auth status:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/auth/logout')
|
||||||
|
globalUser.value = null
|
||||||
|
router.push('/login')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to logout:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.el-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-header {
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
padding-top: 80px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #409EFF;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-footer {
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content a {
|
||||||
|
color: #409EFF;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
15
frontend/src/main.js
Normal file
15
frontend/src/main.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// 配置 axios
|
||||||
|
axios.defaults.withCredentials = true
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
33
frontend/src/router/index.js
Normal file
33
frontend/src/router/index.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import Prices from '../views/Prices.vue'
|
||||||
|
import Providers from '../views/Providers.vue'
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
import Home from '../views/Home.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: Home
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/prices',
|
||||||
|
name: 'prices',
|
||||||
|
component: Prices
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/providers',
|
||||||
|
name: 'providers',
|
||||||
|
component: Providers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: Login
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
236
frontend/src/views/Home.vue
Normal file
236
frontend/src/views/Home.vue
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<el-card class="intro-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<h1>AI模型价格</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="content">
|
||||||
|
<h2>项目简介</h2>
|
||||||
|
<p>这是一个专门用于管理AI模型价格的系统,支持多供应商、多币种的价格管理,并提供标准的API接口供其他系统调用。</p>
|
||||||
|
|
||||||
|
<h2>主要功能</h2>
|
||||||
|
<ul>
|
||||||
|
<li>供应商管理:添加、编辑和删除AI模型供应商</li>
|
||||||
|
<li>价格管理:设置和更新各个模型的价格</li>
|
||||||
|
<li>多币种支持:支持USD和CNY两种货币</li>
|
||||||
|
<li>审核流程:价格变更需要管理员审核</li>
|
||||||
|
<li>API接口:提供标准的REST API</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>API文档</h2>
|
||||||
|
<el-collapse>
|
||||||
|
<el-collapse-item title="获取价格倍率">
|
||||||
|
<div class="api-doc">
|
||||||
|
<div class="api-url">
|
||||||
|
<span class="method">GET</span>
|
||||||
|
<el-tooltip content="点击复制" placement="top">
|
||||||
|
<span class="url" @click="copyToClipboard(origin + '/api/prices/rates')">
|
||||||
|
{{ origin }}/api/prices/rates
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<p>获取所有已审核通过的价格的倍率信息</p>
|
||||||
|
<h4>响应示例:</h4>
|
||||||
|
<pre>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "babbage-002",
|
||||||
|
"type": "tokens",
|
||||||
|
"channel_type": 1,
|
||||||
|
"input": 0.2,
|
||||||
|
"output": 0.2
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
<h4>字段说明:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>model: 模型名称</li>
|
||||||
|
<li>type: 计费类型(tokens/times)</li>
|
||||||
|
<li>channel_type: 供应商ID</li>
|
||||||
|
<li>input: 输入价格倍率</li>
|
||||||
|
<li>output: 输出价格倍率</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<el-collapse-item title="获取价格列表">
|
||||||
|
<div class="api-doc">
|
||||||
|
<div class="api-url">
|
||||||
|
<span class="method">GET</span>
|
||||||
|
<el-tooltip content="点击复制" placement="top">
|
||||||
|
<span class="url" @click="copyToClipboard(origin + '/api/prices')">
|
||||||
|
{{ origin }}/api/prices
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<p>获取所有价格信息,包括待审核的价格</p>
|
||||||
|
<h4>响应示例:</h4>
|
||||||
|
<pre>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"model": "gpt-4",
|
||||||
|
"billing_type": "tokens",
|
||||||
|
"channel_type": "1",
|
||||||
|
"currency": "USD",
|
||||||
|
"input_price": 0.01,
|
||||||
|
"output_price": 0.03,
|
||||||
|
"price_source": "官方",
|
||||||
|
"status": "approved"
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<el-collapse-item title="获取供应商列表">
|
||||||
|
<div class="api-doc">
|
||||||
|
<div class="api-url">
|
||||||
|
<span class="method">GET</span>
|
||||||
|
<el-tooltip content="点击复制" placement="top">
|
||||||
|
<span class="url" @click="copyToClipboard(origin + '/api/providers')">
|
||||||
|
{{ origin }}/api/providers
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<p>获取所有供应商信息</p>
|
||||||
|
<h4>响应示例:</h4>
|
||||||
|
<pre>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "OpenAI",
|
||||||
|
"icon": "https://example.com/openai.png",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
"created_by": "admin"
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc {
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 15px 0 10px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-url {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method {
|
||||||
|
color: #67C23A;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #f0f9eb;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
color: #409EFF;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const origin = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
origin.value = window.location.origin
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加页面元信息
|
||||||
|
const meta = {
|
||||||
|
title: 'AI模型价格 - 首页',
|
||||||
|
description: '专业的AI模型价格管理系统,支持多供应商、多币种的价格管理,提供标准的API接口。',
|
||||||
|
keywords: 'AI模型,价格管理,API接口,OpenAI,Azure,Anthropic'
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ meta })
|
||||||
|
</script>
|
62
frontend/src/views/Login.vue
Normal file
62
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<el-card class="login-card">
|
||||||
|
<template #header>
|
||||||
|
<h2>登录</h2>
|
||||||
|
</template>
|
||||||
|
<el-button type="primary" @click="handleLogin" :loading="loading">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const updateGlobalUser = inject('updateGlobalUser')
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await axios.post('/api/auth/login')
|
||||||
|
const { data } = await axios.get('/api/auth/status')
|
||||||
|
updateGlobalUser(data.user)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/prices')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to login:', error)
|
||||||
|
ElMessage.error('登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
483
frontend/src/views/Prices.vue
Normal file
483
frontend/src/views/Prices.vue
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prices">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span>价格列表</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="handleAdd">提交价格</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-label">厂商筛选:</div>
|
||||||
|
<div class="provider-filters">
|
||||||
|
<el-button
|
||||||
|
:type="!selectedProvider ? 'primary' : ''"
|
||||||
|
@click="selectedProvider = ''"
|
||||||
|
>全部</el-button>
|
||||||
|
<el-button
|
||||||
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
|
:type="selectedProvider === provider.id.toString() ? 'primary' : ''"
|
||||||
|
@click="selectedProvider = provider.id.toString()"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-image
|
||||||
|
v-if="provider.icon"
|
||||||
|
:src="provider.icon"
|
||||||
|
style="width: 16px; height: 16px"
|
||||||
|
/>
|
||||||
|
<span>{{ provider.name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="filteredPrices" style="width: 100%">
|
||||||
|
<el-table-column label="模型">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ row.model }}</div>
|
||||||
|
<div v-if="row.temp_model" class="pending-update">
|
||||||
|
待审核: {{ row.temp_model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="计费类型">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ getBillingType(row.billing_type) }}</div>
|
||||||
|
<div v-if="row.temp_billing_type" class="pending-update">
|
||||||
|
待审核: {{ getBillingType(row.temp_billing_type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="模型厂商">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-image
|
||||||
|
v-if="getProvider(row.channel_type)?.icon"
|
||||||
|
:src="getProvider(row.channel_type)?.icon"
|
||||||
|
style="width: 24px; height: 24px"
|
||||||
|
/>
|
||||||
|
<span>{{ getProvider(row.channel_type)?.name || row.channel_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.temp_channel_type" class="pending-update">
|
||||||
|
待审核: {{ getProvider(row.temp_channel_type)?.name || row.temp_channel_type }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="货币">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ row.currency }}</div>
|
||||||
|
<div v-if="row.temp_currency" class="pending-update">
|
||||||
|
待审核: {{ row.temp_currency }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输入价格(M)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ row.input_price }}</div>
|
||||||
|
<div v-if="row.temp_input_price !== null" class="pending-update">
|
||||||
|
待审核: {{ row.temp_input_price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输出价格(M)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ row.output_price }}</div>
|
||||||
|
<div v-if="row.temp_output_price !== null" class="pending-update">
|
||||||
|
待审核: {{ row.temp_output_price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输入倍率">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ calculateRate(row.input_price, row.currency) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输出倍率">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ calculateRate(row.output_price, row.currency) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格来源">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div>{{ row.price_source }}</div>
|
||||||
|
<div v-if="row.temp_price_source" class="pending-update">
|
||||||
|
待审核: {{ row.temp_price_source }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getStatus(row.status) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_by" label="创建者" />
|
||||||
|
<el-table-column v-if="isAdmin" label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
<el-button type="success" size="small" @click="updateStatus(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="updateStatus(row.id, 'rejected')" :disabled="row.status !== 'pending'">拒绝</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="提交价格">
|
||||||
|
<el-form :model="form" label-width="120px">
|
||||||
|
<el-form-item label="模型">
|
||||||
|
<el-input v-model="form.model" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计费类型">
|
||||||
|
<el-select v-model="form.billing_type" placeholder="请选择">
|
||||||
|
<el-option label="按量计费" value="tokens" />
|
||||||
|
<el-option label="按次计费" value="times" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型厂商">
|
||||||
|
<el-select v-model="form.channel_type" placeholder="请选择">
|
||||||
|
<el-option
|
||||||
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
|
:label="provider.name"
|
||||||
|
:value="provider.id.toString()"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-image
|
||||||
|
v-if="provider.icon"
|
||||||
|
:src="provider.icon"
|
||||||
|
style="width: 24px; height: 24px"
|
||||||
|
/>
|
||||||
|
<span>{{ provider.name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="货币">
|
||||||
|
<el-select v-model="form.currency" placeholder="请选择">
|
||||||
|
<el-option label="美元" value="USD" />
|
||||||
|
<el-option label="人民币" value="CNY" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="输入价格(M)">
|
||||||
|
<el-input-number v-model="form.input_price" :precision="4" :step="0.0001" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="输出价格(M)">
|
||||||
|
<el-input-number v-model="form.output_price" :precision="4" :step="0.0001" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格来源">
|
||||||
|
<el-input v-model="form.price_source" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const prices = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const form = ref({
|
||||||
|
model: '',
|
||||||
|
billing_type: 'tokens',
|
||||||
|
channel_type: '',
|
||||||
|
currency: 'USD',
|
||||||
|
input_price: 0,
|
||||||
|
output_price: 0,
|
||||||
|
price_source: '',
|
||||||
|
created_by: ''
|
||||||
|
})
|
||||||
|
const router = useRouter()
|
||||||
|
const selectedProvider = ref('')
|
||||||
|
|
||||||
|
const isAdmin = computed(() => props.user?.role === 'admin')
|
||||||
|
|
||||||
|
const providers = ref([])
|
||||||
|
const getProvider = (id) => providers.value.find(p => p.id.toString() === id)
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
'pending': '待审核',
|
||||||
|
'approved': '已通过',
|
||||||
|
'rejected': '已拒绝'
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingTypeMap = {
|
||||||
|
'tokens': '按量计费',
|
||||||
|
'times': '按次计费'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatus = (status) => statusMap[status] || status
|
||||||
|
const getBillingType = (type) => billingTypeMap[type] || type
|
||||||
|
|
||||||
|
const calculateRate = (price, currency) => {
|
||||||
|
if (!price) return 0
|
||||||
|
return currency === 'USD' ? (price / 2).toFixed(4) : (price / 14).toFixed(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPrices = computed(() => {
|
||||||
|
if (!selectedProvider.value) return prices.value
|
||||||
|
return prices.value.filter(p => p.channel_type === selectedProvider.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const editingPrice = ref(null)
|
||||||
|
|
||||||
|
const loadPrices = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get('/api/prices')
|
||||||
|
prices.value = data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load prices:', error)
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (price) => {
|
||||||
|
editingPrice.value = price
|
||||||
|
form.value = { ...price }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (price) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'确定要删除这个价格吗?',
|
||||||
|
'警告',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/prices/${price.id}`)
|
||||||
|
await loadPrices()
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete price:', error)
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
ElMessage.error(error.response.data.error)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!props.user) {
|
||||||
|
router.push('/login')
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editingPrice.value = null
|
||||||
|
form.value = {
|
||||||
|
model: '',
|
||||||
|
billing_type: 'tokens',
|
||||||
|
channel_type: '',
|
||||||
|
currency: 'USD',
|
||||||
|
input_price: 0,
|
||||||
|
output_price: 0,
|
||||||
|
price_source: '',
|
||||||
|
created_by: ''
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
try {
|
||||||
|
form.value.created_by = props.user.username
|
||||||
|
let response
|
||||||
|
if (editingPrice.value) {
|
||||||
|
// 更新已存在的价格
|
||||||
|
response = await axios.put(`/api/prices/${editingPrice.value.id}`, form.value)
|
||||||
|
} else {
|
||||||
|
// 检查模型是否已存在
|
||||||
|
const existingPrice = prices.value?.find(p =>
|
||||||
|
p.model === form.value.model &&
|
||||||
|
p.channel_type === form.value.channel_type
|
||||||
|
)
|
||||||
|
if (existingPrice) {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'该模型价格已存在,是否要更新?',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '更新',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
response = await axios.put(`/api/prices/${existingPrice.id}`, form.value)
|
||||||
|
handleSubmitResponse(response)
|
||||||
|
}).catch(() => {
|
||||||
|
// 用户取消更新
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 创建新价格
|
||||||
|
response = await axios.post('/api/prices', form.value)
|
||||||
|
}
|
||||||
|
handleSubmitResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit price:', error)
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
ElMessage.error(error.response.data.error)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitResponse = async (response) => {
|
||||||
|
const { data } = response
|
||||||
|
if (data.error) {
|
||||||
|
ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadPrices()
|
||||||
|
dialogVisible.value = false
|
||||||
|
ElMessage.success(editingPrice.value ? '更新成功' : '添加成功')
|
||||||
|
editingPrice.value = null
|
||||||
|
form.value = {
|
||||||
|
model: '',
|
||||||
|
billing_type: 'tokens',
|
||||||
|
channel_type: '',
|
||||||
|
currency: 'USD',
|
||||||
|
input_price: 0,
|
||||||
|
output_price: 0,
|
||||||
|
price_source: '',
|
||||||
|
created_by: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus = async (id, status) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.put(`/api/prices/${id}/status`, { status })
|
||||||
|
await loadPrices()
|
||||||
|
ElMessage.success(data.message || '更新成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update status:', error)
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
ElMessage.error(error.response.data.error)
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
ElMessage.error('请先登录')
|
||||||
|
router.push('/login')
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
ElMessage.error('需要管理员权限')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPrices()
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get('/api/providers')
|
||||||
|
providers.value = data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load providers:', error)
|
||||||
|
ElMessage.error('加载供应商数据失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding-right: 20px;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
margin: 0 !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prices {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-update {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #E6A23C;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: #FDF6EC;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
217
frontend/src/views/Providers.vue
Normal file
217
frontend/src/views/Providers.vue
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="providers">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>供应商列表</span>
|
||||||
|
<el-button type="primary" @click="handleAdd">添加供应商</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="sortedProviders" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column label="名称">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.name }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="图标" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image
|
||||||
|
v-if="row.icon"
|
||||||
|
:src="row.icon"
|
||||||
|
style="width: 24px; height: 24px"
|
||||||
|
:preview-src-list="[row.icon]"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_by" label="创建者" />
|
||||||
|
<el-table-column v-if="isAdmin" label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle">
|
||||||
|
<el-form :model="form" label-width="80px">
|
||||||
|
<el-form-item label="ID" v-if="!editingProvider">
|
||||||
|
<el-input-number v-model="form.id" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="form.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图标链接">
|
||||||
|
<el-input v-model="form.icon" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const providers = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingProvider = ref(null)
|
||||||
|
const form = ref({
|
||||||
|
id: 1,
|
||||||
|
name: '',
|
||||||
|
icon: ''
|
||||||
|
})
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isAdmin = computed(() => props.user?.role === 'admin')
|
||||||
|
|
||||||
|
// 按ID排序的供应商列表
|
||||||
|
const sortedProviders = computed(() => {
|
||||||
|
return [...providers.value].sort((a, b) => a.id - b.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadProviders = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get('/api/providers')
|
||||||
|
providers.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load providers:', error)
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProviders()
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (editingProvider.value) {
|
||||||
|
return '编辑供应商'
|
||||||
|
}
|
||||||
|
return '添加供应商'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEdit = (provider) => {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
ElMessage.warning('只有管理员可以编辑供应商信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editingProvider.value = provider
|
||||||
|
form.value = { ...provider }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (provider) => {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
ElMessage.warning('只有管理员可以删除供应商')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'确定要删除这个供应商吗?',
|
||||||
|
'警告',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/providers/${provider.id}`)
|
||||||
|
providers.value = providers.value.filter(p => p.id !== provider.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete provider:', error)
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
ElMessage.error('没有权限执行此操作')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!props.user) {
|
||||||
|
router.push('/login')
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
form.value = { id: 1, name: '', icon: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
try {
|
||||||
|
if (editingProvider.value) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
ElMessage.error('只有管理员可以编辑供应商信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 管理员编辑供应商
|
||||||
|
const { data } = await axios.put(`/api/providers/${editingProvider.value.id}`, form.value)
|
||||||
|
if (data.error) {
|
||||||
|
ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = providers.value.findIndex(p => p.id === editingProvider.value.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
providers.value[index] = data
|
||||||
|
}
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
// 创建新供应商
|
||||||
|
const { data } = await axios.post('/api/providers', form.value)
|
||||||
|
if (data.error) {
|
||||||
|
ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providers.value = providers.value ? [...providers.value, data] : [data]
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
editingProvider.value = null
|
||||||
|
form.value = { id: 1, name: '', icon: '' }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit provider:', error)
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
ElMessage.error(error.response.data.error)
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
ElMessage.error('没有权限执行此操作')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
publicDir: 'public',
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"imports": {
|
|
||||||
"std/": "https://deno.land/std@0.220.1/",
|
|
||||||
"deno/": "https://deno.land/x/deno@v1.40.5/"
|
|
||||||
}
|
|
||||||
}
|
|
1375
prices.json
1375
prices.json
File diff suppressed because one or more lines are too long
10
scripts/start.sh
Normal file
10
scripts/start.sh
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# 启动后端服务
|
||||||
|
./main &
|
||||||
|
|
||||||
|
# 启动前端服务
|
||||||
|
cd /app/frontend && node server.js &
|
||||||
|
|
||||||
|
# 启动 nginx
|
||||||
|
nginx -g 'daemon off;'
|
Loading…
x
Reference in New Issue
Block a user