Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a68ff6162 | ||
|
|
6b2d78fe56 | ||
|
|
9c604460b1 | ||
|
|
8d34f8d6fe | ||
|
|
6d1d0f3b6b | ||
|
|
24529189d9 | ||
|
|
000162b1b1 | ||
|
|
6662ea5e04 | ||
|
|
5789d50e9e | ||
|
|
ca3d89751d | ||
|
|
2bc857cf88 | ||
|
|
a9ff7e1c94 | ||
|
|
51d4651c6c | ||
|
|
e112f3af12 | ||
|
|
73e53c2333 | ||
|
|
470e49b850 | ||
|
|
d426781e47 | ||
|
|
b80f0759a5 | ||
|
|
b83c6d9786 | ||
|
|
fe0f2a7e88 | ||
|
|
ed42f3ded7 | ||
|
|
54246c542a | ||
|
|
ca305f4199 | ||
|
|
f8e539c9b4 | ||
|
|
e0b531c578 | ||
|
|
afb51b0a94 | ||
|
|
3c4619b98c | ||
|
|
efd38034ac | ||
|
|
d4bf227cd8 | ||
|
|
fca67cae40 | ||
|
|
20c3cda4a7 | ||
|
|
9045130778 | ||
|
|
5403ed0cba | ||
|
|
87778326ea | ||
|
|
a32a9543e2 | ||
|
|
9e0c33ad93 | ||
|
|
c8e565c6a4 | ||
|
|
9ed162ff0c | ||
|
|
b0d68ba338 | ||
|
|
d4cbc27a77 | ||
|
|
2360e7d2bf | ||
|
|
81845e3f30 | ||
|
|
15f17f4e8d | ||
|
|
ffb4496fd8 | ||
|
|
7bb8f8d012 | ||
|
|
e7ffc9e8b9 | ||
|
|
1e00905dcb | ||
|
|
bc223d6530 | ||
|
|
8b5c6feb5e | ||
|
|
65d6d12972 | ||
|
|
1f5e1c221c | ||
|
|
24bac8e38d | ||
|
|
fb5b1a55ae | ||
|
|
07bcabc5d4 | ||
|
|
8f6f3313f5 | ||
|
|
f8b364a052 |
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
test/
|
test/
|
||||||
|
demo/
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
demo/
|
.env
|
||||||
@@ -77,9 +77,6 @@ wget https://github.com/mirrors2/opencatd-open/raw/main/docker/docker-compose.ym
|
|||||||
- [Fly.io](https://fly.io/)
|
- [Fly.io](https://fly.io/)
|
||||||
- 或者其他
|
- 或者其他
|
||||||
|
|
||||||
修改openai的endpoint地址?使用任意上游地址(套娃代理)
|
|
||||||
- 设置环境变量 openai_endpoint
|
|
||||||
|
|
||||||
使用Nginx + Docker部署
|
使用Nginx + Docker部署
|
||||||
- [使用Nginx + Docker部署](./doc/deploy.md)
|
- [使用Nginx + Docker部署](./doc/deploy.md)
|
||||||
|
|
||||||
@@ -87,12 +84,13 @@ pandora for team
|
|||||||
- [pandora for team](./doc/pandora.md)
|
- [pandora for team](./doc/pandora.md)
|
||||||
|
|
||||||
如何自定义HOST地址? (仅OpenAI)
|
如何自定义HOST地址? (仅OpenAI)
|
||||||
- 需修改环境变量,优先级递增
|
- 需修改环境变量,优先级递增(全局配置谨慎修改)
|
||||||
- Cloudflare AI Gateway地址 `AIGateWay_Endpoint=https://gateway.ai.cloudflare.com/v1/123456789/xxxx/openai/chat/completions`
|
- Cloudflare AI Gateway地址 `AIGateWay_Endpoint=https://gateway.ai.cloudflare.com/v1/123456789/xxxx/openai/chat/completions`
|
||||||
- 自定义的endpoint `$CUSTOM_ENDPOINT=true && $OpenAI_Endpoint=https://your.domain/v1/chat/completions`
|
- 自定义的endpoint `OpenAI_Endpoint=https://your.domain/v1/chat/completions`
|
||||||
|
|
||||||
设置主页跳转地址?
|
设置主页跳转地址?
|
||||||
- 修改环境变量 `CUSTOM_REDIRECT=https://your.domain`
|
- 修改环境变量 `CUSTOM_REDIRECT=https://your.domain`
|
||||||
|
|
||||||
## 获取更多信息
|
## 获取更多信息
|
||||||
[](https://t.me/OpenTeamLLM)
|
[](https://t.me/OpenTeamLLM)
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 35 KiB |
BIN
assets/openteam.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
63
cmd/openteam/main.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/internal/cli"
|
||||||
|
"opencatd-open/internal/consts"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"opencatd-open/pkg/store"
|
||||||
|
"opencatd-open/router"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist/*
|
||||||
|
var web embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.InitDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "openteam",
|
||||||
|
Short: "openteam cli",
|
||||||
|
Long: consts.Logo,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
router.SetRouter(cfg, db, &web)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(cli.LoadCmd)
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printFilesAndDirs(fsys fs.FS, prefix string) error {
|
||||||
|
return fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
fmt.Printf("%s[DIR] %s\n", prefix, p)
|
||||||
|
} else {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s[FILE] %s (%d bytes)\n", prefix, p, info.Size())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
30
deploy/docker/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /frontend-build
|
||||||
|
COPY ./frontend .
|
||||||
|
|
||||||
|
RUN npm install -g pnpm && pnpm i && pnpm build
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS backend
|
||||||
|
LABEL anther="github.com/Sakurasan"
|
||||||
|
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
RUN apk --no-cache add make cmake upx
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
# ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
CMD [ "go mod tidy","go mod download" ]
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
FROM alpine:latest AS runner
|
||||||
|
# 设置alpine 时间为上海时间
|
||||||
|
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
RUN apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /build/bin/openteam /app/openteam
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ENV PATH=$PATH:/app
|
||||||
|
EXPOSE 80 443
|
||||||
|
ENTRYPOINT ["/app/openteam"]
|
||||||
30
deploy/docker/Dockerfile.cn
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /frontend-build
|
||||||
|
COPY ./frontend .
|
||||||
|
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && npm install -g pnpm --registry=https://registry.npmmirror.com && pnpm i && pnpm build
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS backend
|
||||||
|
LABEL anther="github.com/Sakurasan"
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
RUN apk --no-cache add make cmake upx
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
CMD [ "go mod tidy","go mod download" ]
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
FROM alpine:latest AS runner
|
||||||
|
# 设置alpine 时间为上海时间
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
RUN apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /build/bin/openteam /app/openteam
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ENV PATH=$PATH:/app
|
||||||
|
EXPOSE 80 443
|
||||||
|
ENTRYPOINT ["/app/openteam"]
|
||||||
8
deploy/docker/docker-compose.adminer.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
21
deploy/docker/docker-compose.mariadb.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb
|
||||||
|
container_name: mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- ${PWD}/mysqldb:/var/lib/mysql
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --skip-character-set-client-handshake
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: openteam
|
||||||
|
MYSQL_DATABASE: openteam
|
||||||
|
MYSQL_USER: openteam
|
||||||
|
MYSQL_PASSWORD: openteam
|
||||||
|
|
||||||
22
deploy/docker/docker-compose.pg.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# CREATE EXTENSION vector;
|
||||||
|
# SELECT * FROM pg_extension;
|
||||||
|
# SELECT * FROM pg_available_extensions;
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
# image: paradedb/paradedb
|
||||||
|
container_name: pg
|
||||||
|
restart: always
|
||||||
|
# network_mode: host
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: openteam
|
||||||
|
POSTGRES_USER: openteam
|
||||||
|
POSTGRES_PASSWORD: openteam
|
||||||
|
volumes:
|
||||||
|
- $PWD/pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
10
deploy/docker/docker-compose.sqlite.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
version: '3.7'
|
||||||
|
services:
|
||||||
|
sqlite-web:
|
||||||
|
image: vaalacat/sqlite-web
|
||||||
|
ports:
|
||||||
|
- 8800:8080
|
||||||
|
volumes:
|
||||||
|
- $PWD/db:/data
|
||||||
|
environment:
|
||||||
|
- SQLITE_DATABASE=openteam.db
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Email: admin@example.com
|
|
||||||
# Password: changeme
|
|
||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
npm:
|
|
||||||
image: jc21/nginx-proxy-manager
|
|
||||||
network_mode: host
|
|
||||||
ports:
|
|
||||||
- '80:80'
|
|
||||||
- '81:81'
|
|
||||||
- '443:443'
|
|
||||||
volumes:
|
|
||||||
- $PWD/data:/data
|
|
||||||
- $PWD/www:/var/www
|
|
||||||
- $PWD/letsencrypt:/etc/letsencrypt
|
|
||||||
environment:
|
|
||||||
- "TZ=Asia/Shanghai" # set timezone, default UTC
|
|
||||||
- "PUID=1000" # set group id, default 0 (root)
|
|
||||||
- "PGID=1000"
|
|
||||||
|
|
||||||
# certbot:
|
|
||||||
# image: certbot/certbot
|
|
||||||
# volumes:
|
|
||||||
# - $PWD/data/certbot/conf:/etc/letsencrypt
|
|
||||||
# - $PWD/data/certbot/www:/var/www/certbot
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
FROM node:20-alpine AS frontend
|
|
||||||
WORKDIR /frontend-build
|
|
||||||
COPY ./web/ .
|
|
||||||
RUN npm install && npm run build && rm -rf node_modules
|
|
||||||
|
|
||||||
FROM golang:1.23-alpine as builder
|
|
||||||
LABEL anther="github.com/Sakurasan"
|
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk --no-cache add make cmake upx
|
|
||||||
WORKDIR /build
|
|
||||||
COPY --from=frontend /frontend-build/dist /build/dist
|
|
||||||
COPY . /build
|
|
||||||
ENV GO111MODULE=on
|
|
||||||
# ENV GOPROXY=https://goproxy.cn,direct
|
|
||||||
CMD [ "go mod tidy","go mod download" ]
|
|
||||||
RUN make build
|
|
||||||
|
|
||||||
FROM alpine:latest AS runner
|
|
||||||
# 设置alpine 时间为上海时间
|
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone
|
|
||||||
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /build/bin/opencatd /app/opencatd
|
|
||||||
ENV GIN_MODE=release
|
|
||||||
ENV PATH=$PATH:/app
|
|
||||||
EXPOSE 80
|
|
||||||
ENTRYPOINT ["/app/opencatd"]
|
|
||||||
0
web/.gitignore → frontend/.gitignore
vendored
3
frontend/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# OpenTeam Frontend
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="emerald">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="src/assets/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>opencatd-open</title>
|
<title>OpenTeam</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"element-plus": "^2.9.7",
|
||||||
|
"lucide-vue-next": "^0.479.0",
|
||||||
|
"qrcode.vue": "^3.6.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/mingcute": "^1.2.3",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.32",
|
||||||
|
"@iconify/vue": "^4.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"daisyui": "^4.12.24",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2301
frontend/pnpm-lock.yaml
generated
Normal file
1
frontend/public/assets/anthropic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
1
frontend/public/assets/azure.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
frontend/public/assets/bedrock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
frontend/public/assets/claude.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/assets/gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 581 B |
1
frontend/public/assets/github.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 913 B |
3
frontend/public/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
1
frontend/public/assets/openai.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/assets/openteam.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
3
frontend/public/assets/openteam.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/public/assets/openteam.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/assets/openteam_200x200.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/assets/openteam_bg_white.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
frontend/public/assets/openteam_channel.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
24
frontend/src/App.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
<Toast :queue="toastQueue" />
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, provide } from 'vue';
|
||||||
|
import Toast from './components/Toast.vue';
|
||||||
|
|
||||||
|
|
||||||
|
const toastQueue = ref([]);
|
||||||
|
|
||||||
|
const setToast = (message, type = 'info', duration) => {
|
||||||
|
toastQueue.value.push({ message, type, duration });
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// authStore.checkLoginStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
provide('toast', { setToast });
|
||||||
|
</script>
|
||||||
1
frontend/src/assets/anthropic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
1
frontend/src/assets/azure.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
frontend/src/assets/bedrock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
frontend/src/assets/claude.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 581 B |
1
frontend/src/assets/github.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 913 B |
3
frontend/src/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
1
frontend/src/assets/openai.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/openteam.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
3
frontend/src/assets/openteam.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/src/assets/openteam.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/openteam_200x200.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/src/assets/openteam_bg_white.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
frontend/src/assets/openteam_channel.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
277
frontend/src/components/LineSegmentFlow.vue
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] backdrop-blur-0 px-4 py-0 my-0 rounded-lg overflow-hidden ">
|
||||||
|
<!-- bg-gradient-to-br from-slate-50 to-orange-50 -->
|
||||||
|
<!-- 中心图标容器 -->
|
||||||
|
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||||||
|
<!-- 中心图标本身 -->
|
||||||
|
<div class="w-10 h-10 md:w-16 md:h-16 rounded-full flex items-center justify-center backdrop-blur-md animate-bounce hover:cursor-alias" @click="$router.push('/dashboard')">
|
||||||
|
<img src="../assets/logo.svg" alt="Center Logo" class="rounded-full object-cover">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左侧图标列 -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
|
||||||
|
<!-- 遍历左侧图标数据 -->
|
||||||
|
<div v-for="icon in leftIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
|
||||||
|
class="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12 flex items-center justify-center">
|
||||||
|
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
|
||||||
|
<div v-else
|
||||||
|
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧图标列 -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
|
||||||
|
<!-- 遍历右侧图标数据 -->
|
||||||
|
<div v-for="icon in rightIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
|
||||||
|
class="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12 flex items-center justify-center">
|
||||||
|
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
|
||||||
|
<div v-else
|
||||||
|
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG 画布,用于绘制线条和动画 -->
|
||||||
|
<svg class="absolute inset-0 w-full h-full z-0" ref="svgCanvas">
|
||||||
|
<defs>
|
||||||
|
<!-- 这里可以定义 SVG 渐变或标记 (marker) -->
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 只有当中心点和图标坐标都计算好后才开始绘制 -->
|
||||||
|
<g v-if="centerCoords && Object.keys(iconCoords).length >= (leftIcons.length + rightIcons.length)">
|
||||||
|
<!-- 绘制左侧图标的线条和动画 -->
|
||||||
|
<template v-for="icon in leftIcons" :key="'group-left-' + icon.id">
|
||||||
|
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
|
||||||
|
<path :id="'path-visual-left-' + icon.id"
|
||||||
|
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')" stroke="#E5E7EB"
|
||||||
|
stroke-width="1" fill="none" />
|
||||||
|
|
||||||
|
<!-- 2. 绘制用于动画的、覆盖在背景线上的短线段 -->
|
||||||
|
<path :id="'path-anim-left-' + icon.id"
|
||||||
|
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
|
||||||
|
:stroke="icon.color || '#DB2777'" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||||||
|
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="largeGap + dashLen">
|
||||||
|
<!-- 定义动画:改变 stroke-dashoffset 使短线段移动 -->
|
||||||
|
<animate attributeName="stroke-dashoffset" :from="largeGap + dashLen" :to="0"
|
||||||
|
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
|
||||||
|
repeatCount="indefinite" fill="freeze" />
|
||||||
|
<!-- keyTimes 和 values 可以更精细控制,但这里 from/to 足够 -->
|
||||||
|
</path>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 绘制右侧图标的线条和动画 -->
|
||||||
|
<template v-for="icon in rightIcons" :key="'group-right-' + icon.id">
|
||||||
|
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
|
||||||
|
<path :id="'path-visual-right-' + icon.id"
|
||||||
|
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right')" stroke="#E5E7EB"
|
||||||
|
stroke-width="1" fill="none" />
|
||||||
|
|
||||||
|
<!-- 2. 绘制用于动画的、覆盖在背景线上的短线段 -->
|
||||||
|
<path :id="'path-anim-right-' + icon.id"
|
||||||
|
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
|
||||||
|
:stroke="icon.color || '#1D4ED8'" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||||||
|
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="0">
|
||||||
|
<!-- 定义动画:改变 stroke-dashoffset 使短线段移动 -->
|
||||||
|
<!-- 注意:路径本身是从 Icon 到 Center 绘制的。为了让动画看起来是从 Center 到 Icon, -->
|
||||||
|
<!-- 我们需要让 dashoffset 从 0 (在Icon处开始) 变为 负的pattern长度 (移动到Center处结束) -->
|
||||||
|
<animate attributeName="stroke-dashoffset" :from="0" :to="-(largeGap + dashLen)"
|
||||||
|
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
|
||||||
|
repeatCount="indefinite" fill="freeze" />
|
||||||
|
</path>
|
||||||
|
</template>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick, reactive, computed } from 'vue'; // 引入 computed
|
||||||
|
|
||||||
|
// --- 图标数据 (保持不变) ---
|
||||||
|
const leftIcons = ref([
|
||||||
|
{ id: 'web', name: 'Web', img: 'https://img.icons8.com/?size=100&id=38536&format=png&color=000000', color: '#DB4437' },
|
||||||
|
{ id: 'iphone', name: 'iPhone', img: 'https://img.icons8.com/?size=100&id=ZwGNoFXGbt9n&format=png&color=000000', color: '#eac50c' },
|
||||||
|
{ id: 'mac', name: 'Mac', img: 'https://img.icons8.com/?size=100&id=RHxDgbKmJhUD&format=png&color=000000', color: '#1DB954' },
|
||||||
|
]);
|
||||||
|
const rightIcons = ref([
|
||||||
|
{ id: 'openai', name: 'OpenAI', img: 'https://img.icons8.com/?size=100&id=FBO05Dys9QCg&format=png&color=000000', color: '#E4405F' },
|
||||||
|
{ id: 'claude', name: 'Claude', img: 'https://img.icons8.com/?size=100&id=H5H0mqCCr5AV&format=png&color=000000', color: '#229ED9' },
|
||||||
|
{ id: 'gemini', name: 'Gemini', img: 'https://img.icons8.com/?size=100&id=eoxMN35Z6JKg&format=png&color=000000', color: '#FF6600' },
|
||||||
|
{ id: 'azure', name: 'Azure', img: 'https://img.icons8.com/?size=100&id=VLKafOkk3sBX&format=png&color=000000', color: '#007FFF' },
|
||||||
|
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' },
|
||||||
|
{ id: 'google', name: 'Google', img: 'https://img.icons8.com/color/48/google-logo.png', color: '#DB4437' },
|
||||||
|
{ id: 'deepseek', name: 'DeepSeek', img: 'https://img.icons8.com/?size=100&id=YWOidjGxCpFW&format=png&color=000000', color: '#4CAF50' },
|
||||||
|
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#333' },
|
||||||
|
]);
|
||||||
|
// --- 结束图标数据 ---
|
||||||
|
|
||||||
|
// --- Dash 动画参数 ---
|
||||||
|
const dashLen = ref(15); // 移动线段的长度
|
||||||
|
const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线段可见
|
||||||
|
// --- 结束 Dash 动画参数 ---
|
||||||
|
|
||||||
|
|
||||||
|
const svgCanvas = ref(null);
|
||||||
|
const centerElement = ref(null);
|
||||||
|
const iconRefs = reactive({});
|
||||||
|
const centerCoords = ref(null);
|
||||||
|
const iconCoords = reactive({});
|
||||||
|
|
||||||
|
// (getElementCenterCoords 和 updateCoordinates 函数保持不变)
|
||||||
|
const getElementCenterCoords = (element) => {
|
||||||
|
if (!element || !svgCanvas.value) return null;
|
||||||
|
const svgRect = svgCanvas.value.getBoundingClientRect();
|
||||||
|
const elemRect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: elemRect.left + elemRect.width / 2 - svgRect.left,
|
||||||
|
y: elemRect.top + elemRect.height / 2 - svgRect.top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const updateCoordinates = () => {
|
||||||
|
if (!centerElement.value || !svgCanvas.value) return;
|
||||||
|
centerCoords.value = getElementCenterCoords(centerElement.value);
|
||||||
|
const allIcons = [...leftIcons.value, ...rightIcons.value];
|
||||||
|
let coordsFound = 0;
|
||||||
|
allIcons.forEach(icon => {
|
||||||
|
const element = iconRefs[icon.id];
|
||||||
|
if (element) {
|
||||||
|
iconCoords[icon.id] = getElementCenterCoords(element);
|
||||||
|
if (iconCoords[icon.id]) {
|
||||||
|
coordsFound++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`找不到图标 ${icon.id} 的 DOM 元素引用。`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// if (coordsFound < allIcons.length) { // 可选的调试信息
|
||||||
|
// console.warn("部分图标坐标未能成功计算。");
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
|
||||||
|
/**
|
||||||
|
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
|
||||||
|
* @param {object} iconCoord 图标坐标 {x, y}
|
||||||
|
* @param {object} centerCoord 中心坐标 {x, y}
|
||||||
|
* @param {'left' | 'right'} side 图标在哪一侧
|
||||||
|
* @returns {string} SVG path 'd' 属性字符串
|
||||||
|
*/
|
||||||
|
const calculatePathForVisual = (iconCoord, centerCoord, side) => {
|
||||||
|
if (!iconCoord || !centerCoord) return '';
|
||||||
|
const { x: startX, y: startY } = iconCoord;
|
||||||
|
const { x: endX, y: endY } = centerCoord;
|
||||||
|
const controlX = (side === 'left')
|
||||||
|
? startX + (endX - startX) * 0.6
|
||||||
|
: startX - (startX - endX) * 0.6;
|
||||||
|
const controlY = startY;
|
||||||
|
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算动画运动的 SVG 路径
|
||||||
|
* @param {object} iconCoord 图标坐标 {x, y}
|
||||||
|
* @param {object} centerCoord 中心坐标 {x, y}
|
||||||
|
* @param {'left' | 'right'} side 图标在哪一侧
|
||||||
|
* @param {'toCenter' | 'fromCenter'} direction 动画方向
|
||||||
|
* @returns {string} SVG path 'd' 属性字符串
|
||||||
|
*/
|
||||||
|
const calculatePathForAnimation = (iconCoord, centerCoord, side, direction) => {
|
||||||
|
if (!iconCoord || !centerCoord) return '';
|
||||||
|
|
||||||
|
let startX, startY, endX, endY;
|
||||||
|
let controlX, controlY;
|
||||||
|
|
||||||
|
if (direction === 'fromCenter') {
|
||||||
|
// --- 动画从中心开始 ---
|
||||||
|
startX = centerCoord.x;
|
||||||
|
startY = centerCoord.y;
|
||||||
|
endX = iconCoord.x;
|
||||||
|
endY = iconCoord.y;
|
||||||
|
|
||||||
|
// 控制点计算:
|
||||||
|
// 为了使曲线形状看起来与 'toCenter' 类似但方向相反
|
||||||
|
// 我们将控制点放在靠近中心(起点)的位置,并使其 Y 坐标与终点(图标)对齐
|
||||||
|
controlX = startX + (endX - startX) * 0.4; // X 轴方向上,控制点靠近起点 (中心)
|
||||||
|
controlY = endY; // Y 轴方向上,与终点 (图标) 对齐
|
||||||
|
|
||||||
|
} else { // direction === 'toCenter' (默认)
|
||||||
|
// --- 动画从图标开始 ---
|
||||||
|
startX = iconCoord.x;
|
||||||
|
startY = iconCoord.y;
|
||||||
|
endX = centerCoord.x;
|
||||||
|
endY = centerCoord.y;
|
||||||
|
|
||||||
|
// 控制点计算 (与视觉线一致)
|
||||||
|
controlX = (side === 'left')
|
||||||
|
? startX + (endX - startX) * 0.6
|
||||||
|
: startX - (startX - endX) * 0.6;
|
||||||
|
controlY = startY; // Y 轴方向上,与起点 (图标) 对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- 生命周期钩子 (保持不变) ---
|
||||||
|
let resizeObserver;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
updateCoordinates();
|
||||||
|
resizeObserver = new ResizeObserver(updateCoordinates);
|
||||||
|
if (svgCanvas.value?.parentElement) {
|
||||||
|
resizeObserver.observe(svgCanvas.value.parentElement);
|
||||||
|
} else {
|
||||||
|
console.warn("无法找到用于 ResizeObserver 的父元素。");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
/* Prevent text/image selection */
|
||||||
|
user-select: none;
|
||||||
|
/* Standard */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
/* Safari, Chrome, Opera */
|
||||||
|
-moz-user-select: none;
|
||||||
|
/* Firefox */
|
||||||
|
-ms-user-select: none;
|
||||||
|
/* IE/Edge */
|
||||||
|
|
||||||
|
/* Prevent dragging ghost image (optional but helpful) */
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-drag: none;
|
||||||
|
/* Maybe needed for some browsers */
|
||||||
|
pointer-events: none;
|
||||||
|
/* Also prevents clicks/hovers directly on the img if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col.justify-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可选:给动画路径添加一点模糊效果? */
|
||||||
|
#path-anim-left,
|
||||||
|
#path-anim-right {
|
||||||
|
filter: blur(2px);
|
||||||
|
background-color: #eac50c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
140
frontend/src/components/Pagination.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col md:flex-row items-center justify-between gap-3 mt-4 text-sm">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
显示 {{ startItem }} 到 {{ endItem }},共 {{ totalItems }} 项
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 md:gap-4 justify-center md:justify-end">
|
||||||
|
<div v-if="showSelectPageSize" class="flex items-center gap-2">
|
||||||
|
<span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">每页显示</span>
|
||||||
|
<select
|
||||||
|
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-16 dark:text-white dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
:value="pageSize"
|
||||||
|
@change="emitChangePageSize($event)"
|
||||||
|
>
|
||||||
|
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="px-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ currentPage }}/{{ totalPages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="emitChangePage(1, pageSize)"
|
||||||
|
aria-label="第一页"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="emitChangePage(currentPage - 1, pageSize)"
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="emitChangePage(currentPage + 1, pageSize)"
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="emitChangePage(totalPages, pageSize)"
|
||||||
|
aria-label="最后一页"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref ,watch} from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
totalItems: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
pageSizeOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [10, 25, 50, 100],
|
||||||
|
},
|
||||||
|
showSelectPageSize: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['changePage', 'changePageSize']);
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用一个 ref 来存储内部的 currentPage 状态
|
||||||
|
const localCurrentPage = ref(props.currentPage);
|
||||||
|
const localPageSize = ref(props.pageSize);
|
||||||
|
|
||||||
|
// 监听 props.currentPage 的变化,并更新 localCurrentPage
|
||||||
|
watch(() => props.currentPage, (newCurrentPage) => {
|
||||||
|
localCurrentPage.value = newCurrentPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.pageSize, (newPageSize) => {
|
||||||
|
localPageSize.value = newPageSize;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitChangePage = (page, pageSize) => { // 添加了 pageSize 参数
|
||||||
|
const validPage = Math.max(1, Math.min(page, totalPages.value));
|
||||||
|
if (validPage !== localCurrentPage.value) {
|
||||||
|
localCurrentPage.value = validPage;
|
||||||
|
emit('changePage', validPage, pageSize); // 将 pageSize 传递给父组件
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChangePageSize = (event) => {
|
||||||
|
const newPageSize = parseInt(event.target.value, 10);
|
||||||
|
localPageSize.value = newPageSize;
|
||||||
|
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = computed(() => {
|
||||||
|
return (localCurrentPage.value - 1) * localPageSize.value + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endItem = computed(() => {
|
||||||
|
return Math.min(localCurrentPage.value * localPageSize.value, props.totalItems);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
119
frontend/src/components/QRCodeCard.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg mt-2 max-w-sm mx-auto backdrop-blur-xl glass">
|
||||||
|
|
||||||
|
<div class="p-3 bg-white rounded-lg shadow-inner cursor-pointer" @click="toggleQRCode">
|
||||||
|
<qrcode-vue :value="currentValue" :size="size" level="H" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full p-4 ">
|
||||||
|
<p
|
||||||
|
class="text-sm break-all whitespace-pre-wrap m-1 p-1 pr-5 text-base-content border border-dashed border-base-content rounded-lg">
|
||||||
|
{{ currentValue }}
|
||||||
|
</p>
|
||||||
|
<button @click="copyValue" class="absolute top-2 right-2 btn btn-xs btn-ghost bg-gray-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2M16 8h2a2 2 0 012 2v8a2 2 0 01-2-2h-8a2 2 0 01-2-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="showCopied"
|
||||||
|
class="absolute -top-5 -right-2 bg-neutral text-neutral-content px-2 py-1 rounded text-xs opacity-100 transition-opacity duration-300">
|
||||||
|
Copied
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 w-full">
|
||||||
|
<button v-for="app in applist" :key="app.name"
|
||||||
|
class="btn btn-sm btn-outline btn-ghost flex items-center justify-center"
|
||||||
|
@click="applyPrefix(app.name)">
|
||||||
|
<img :src="app.url" alt="" class="w-5 h-5 mr-1">
|
||||||
|
<span>{{ app.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<button class="btn btn-outline btn-sm w-full" @click="resetValue">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
import QrcodeVue from 'qrcode.vue';
|
||||||
|
|
||||||
|
// 定义组件接收的 props
|
||||||
|
const props = defineProps({
|
||||||
|
value: { // 二维码的原始值
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: { // 二维码的尺寸 (像素)
|
||||||
|
type: Number,
|
||||||
|
default: 160 // 默认大小
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值
|
||||||
|
// 初始值是来自 props 的 value
|
||||||
|
const currentValue = ref(props.value);
|
||||||
|
|
||||||
|
// 监听 props.value 的变化,如果外部传入的 value 改变,更新 currentValue
|
||||||
|
watch(() => props.value, (newValue) => {
|
||||||
|
currentValue.value = newValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const showCopied = ref(false);
|
||||||
|
|
||||||
|
const copyValue = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentValue.value);
|
||||||
|
|
||||||
|
showCopied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showCopied.value = false;
|
||||||
|
}, 1500); // 1.5 秒后恢复
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
// 可以在这里添加错误提示
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applist = reactive([
|
||||||
|
// { name: 'openteam', url: '/assets/logo.svg' },
|
||||||
|
{ name: 'botgem', url: 'https://botgem.com/favicon.ico' },
|
||||||
|
{ name: 'opencat', url: 'https://opencat.app/favicon.ico' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const applyPrefix = (name) => {
|
||||||
|
let origin = window.location.origin;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'botgem':
|
||||||
|
currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`;
|
||||||
|
break;
|
||||||
|
case 'opencat':
|
||||||
|
currentValue.value = `opencat://team/join?domain=${origin}&token=${props.value}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
currentValue.value = name + props.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleQRCode = () => {
|
||||||
|
if (currentValue.value.startsWith('sk-')) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
window.open(currentValue.value, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除前缀,恢复到原始值
|
||||||
|
const resetValue = () => {
|
||||||
|
currentValue.value = props.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
60
frontend/src/components/Toast.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<!-- src/components/Toast.vue -->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="mt-20 toast "
|
||||||
|
:class="{ 'toast-error': currentMessage?.type === 'error', 'toast-success': currentMessage?.type === 'success' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="alert"
|
||||||
|
:class="{ 'alert-error': currentMessage?.type === 'error', 'alert-success': currentMessage?.type === 'success' }"
|
||||||
|
>
|
||||||
|
<span>{{ currentMessage?.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted, watch, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
queue: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const show = ref(false);
|
||||||
|
const currentMessage = ref(null);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const processQueue = () => {
|
||||||
|
if (props.queue.length === 0) {
|
||||||
|
show.value = false;
|
||||||
|
currentMessage.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMessage.value = props.queue.shift();
|
||||||
|
show.value = true;
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
processQueue();
|
||||||
|
}, currentMessage.value.duration || 3000);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.queue, () => {
|
||||||
|
if(!show.value) {
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
81
frontend/src/components/dashboard/BreadcrumbHeader.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Breadcrumb and Title -->
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h1 class="text-lg font-medium">{{ displayTitle }}</h1>
|
||||||
|
<div class="text-xs breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(item, index) in breadcrumbItems" :key="index">
|
||||||
|
<template v-if="item.path">
|
||||||
|
<a
|
||||||
|
:href="item.path"
|
||||||
|
class="text-gray-500"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-gray-500">{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null // 默认为null,将从路由中获取
|
||||||
|
},
|
||||||
|
// Optional custom breadcrumb items
|
||||||
|
customBreadcrumbs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Generate breadcrumb items based on current route
|
||||||
|
// 生成面包屑项
|
||||||
|
const breadcrumbItems = computed(() => {
|
||||||
|
if (props.customBreadcrumbs.length > 0) {
|
||||||
|
return props.customBreadcrumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前路径并分割成段
|
||||||
|
const pathSegments = route.path.split('/').filter(segment => segment);
|
||||||
|
|
||||||
|
return pathSegments.map((segment, index) => {
|
||||||
|
const name = segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||||
|
|
||||||
|
// 对于最后一段,不设置链接
|
||||||
|
if (index === pathSegments.length - 1) {
|
||||||
|
return { name, path: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建到此段的路径
|
||||||
|
const path = '/' + pathSegments.slice(0, index + 1).join('/');
|
||||||
|
return { name, path };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示的标题:如果提供了自定义标题则使用,否则使用当前路径的最后一段
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (props.title) {
|
||||||
|
return props.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = route.path.split('/').filter(segment => segment);
|
||||||
|
if (pathSegments.length > 0) {
|
||||||
|
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||||
|
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Dashboard';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
98
frontend/src/components/dashboard/Sidebar.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="w-60 bg-base-100 border-r border-base-200 glass">
|
||||||
|
<div class="flex items-center p-4 outline-none select-none backdrop-blur-lg">
|
||||||
|
<div class="w-12 h-12 rounded-full">
|
||||||
|
<img src='@/assets/logo.svg' class="" @click="$router.push('/')" >
|
||||||
|
</div>
|
||||||
|
<div class="p-0 text-2xl font-bold text-center">OpenTeam</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<ul class="menu p-4 w-60 min-h-full bg-base-100 text-base-content">
|
||||||
|
<li v-for="item in menuItems" :key="item.label">
|
||||||
|
<template v-if="item.type === 'link'">
|
||||||
|
<router-link :to="item.to" :class="{ 'active': isActive(item.to) }">
|
||||||
|
<component :is="item.icon" class="w-4" />
|
||||||
|
{{ item.label }}
|
||||||
|
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'title'">
|
||||||
|
<span class="menu-title">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'submenu'">
|
||||||
|
<details :open="item.open">
|
||||||
|
<summary>
|
||||||
|
<component :is="item.icon" class="w-4" />
|
||||||
|
{{ item.label }}
|
||||||
|
<div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li v-for="subItem in item.children" :key="subItem.label">
|
||||||
|
<router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }">
|
||||||
|
<component :is="subItem.icon" class="w-4" />
|
||||||
|
{{ subItem.label }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ShieldPlus,
|
||||||
|
UsersRoundIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
CommandIcon,
|
||||||
|
BracesIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { ref, reactive, onMounted ,computed} from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import {routes,generateMenuItemsFromRoutes}from '@/utils/router_menu.js'
|
||||||
|
import { useAuthStore } from '@/stores/auth.js';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const userrole = computed(() => {
|
||||||
|
const user = authStore.user;
|
||||||
|
return user ? user.role : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
// 判断当前路由是否激活菜单项
|
||||||
|
const isActive = (path) => {
|
||||||
|
return route.path === path;
|
||||||
|
// return router.currentRoute.value.fullPath.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
let menuItems = reactive([
|
||||||
|
{ type: 'link', label: 'Overview', to: '/dashboard/overview', icon: LayoutDashboardIcon },
|
||||||
|
{ type: 'title', label: 'Apps' },
|
||||||
|
{ type: 'link', label: 'Tokens', to: '/dashboard/tokens', icon: BracesIcon },
|
||||||
|
{
|
||||||
|
type: 'submenu', label: 'Manager', icon: CommandIcon, open: true, badge: 'Admin',
|
||||||
|
children: [
|
||||||
|
{ label: 'Users', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||||
|
{ label: 'ApiKeys', to: '/dashboard/manager/keys', icon: KeyRoundIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'submenu', label: 'Settings', icon: SettingsIcon,open: false,
|
||||||
|
children: [
|
||||||
|
{ label: 'Profile', to: '/dashboard/settings/profile', icon: UserIcon },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
menuItems = computed(() => generateMenuItemsFromRoutes(routes, userrole.value));
|
||||||
|
|
||||||
|
</script>
|
||||||
18
frontend/src/main.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import './style.css'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.provide('request', request)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use(router)
|
||||||
|
app.use(pinia)
|
||||||
|
app.mount('#app')
|
||||||
53
frontend/src/router/index.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
|
||||||
|
import { routes } from '@/utils/router_menu.js'
|
||||||
|
|
||||||
|
let defaultroutes = [
|
||||||
|
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
|
||||||
|
{ path: '/404', name: '404', component: () => import('@/views/404.vue') },
|
||||||
|
|
||||||
|
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
|
||||||
|
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') },
|
||||||
|
|
||||||
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') }, // Catch all 404
|
||||||
|
{
|
||||||
|
path: '/dashboard', name: 'Dashboard', component: () => import('@/views/DashBoard.vue'), meta: { requiresAuth: true }, redirect: '/dashboard/overview', children: [
|
||||||
|
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: 'Overview' } },
|
||||||
|
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'Tokens' } },
|
||||||
|
{
|
||||||
|
path: 'manager', name: 'Manager', meta: { title: 'Manager' }, redirect: '/dashboard/manager/users', children: [
|
||||||
|
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: 'Users' } },
|
||||||
|
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: 'UserNew' } },
|
||||||
|
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: 'UserView' } },
|
||||||
|
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: 'Keys' } },
|
||||||
|
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: 'KeyView' } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings', name: 'Settings', meta: { title: 'Settings' }, redirect: '/dashboard/settings/profile', children: [
|
||||||
|
{ path: 'profile', name: 'Profile', component: () => import('@/views/dashboard/Profile.vue'), meta: { title: 'Profile' } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
// const router = createRouter({
|
||||||
|
// history: createWebHistory(process.env.BASE_URL),
|
||||||
|
// routes
|
||||||
|
// })
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const isAuthenticated = localStorage.getItem('token')
|
||||||
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
219
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
// import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const user = ref(null);
|
||||||
|
const role = computed(() => {
|
||||||
|
if (!user.value) return 0;
|
||||||
|
return user.value.role;
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = ref(localStorage.getItem('token') || '');
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
if (!user.value || user.value.role === 0) return false;
|
||||||
|
return user.value.role > 0;
|
||||||
|
});
|
||||||
|
const isLoggedIn = computed(() => !!token.value);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
const setToken = (newToken) => {
|
||||||
|
token.value = newToken;
|
||||||
|
localStorage.setItem('token', newToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTokenFromStorage=()=> {
|
||||||
|
const storedToken = localStorage.getItem('token');
|
||||||
|
if (storedToken) {
|
||||||
|
token.value = storedToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (userInfo) => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await request.post('/auth/register', userInfo)
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '注册失败';
|
||||||
|
throw error // 或者您可以在这里处理错误,例如显示错误消息
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (userInfo) => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await request.post('/auth/login', userInfo)
|
||||||
|
if (res.status === 200 && !!res.data.data?.token) {
|
||||||
|
setToken(res.data.data.token)
|
||||||
|
}
|
||||||
|
await getProfile() // 登录成功后获取用户信息
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '登录失败';
|
||||||
|
throw error // 或者您可以在这里处理错误,例如显示错误消息
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProfile = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
if (token.value && user.value) {
|
||||||
|
return user.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await request.get('/profile')
|
||||||
|
|
||||||
|
user.value = res.data.data
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取用户信息失败';
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshProfile = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await request.get('/profile')
|
||||||
|
if (res.data.code == 200) {
|
||||||
|
user.value = res.data.data
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取用户信息失败';
|
||||||
|
throw error
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProfile = async (userInfo) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await request.post('/profile/update', userInfo)
|
||||||
|
console.log('auth.js updateProfile', res.data);
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '更新用户信息失败';
|
||||||
|
throw error
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePassword = async (payload) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await request.post('/profile/update/password', payload)
|
||||||
|
console.log('auth.js updatePassword', res.data);
|
||||||
|
return res
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '更新密码失败';
|
||||||
|
throw error
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createToken = async (newToken) => {
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post('/tokens', newToken)
|
||||||
|
console.log('createToken', response.data);
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '创建token失败';
|
||||||
|
throw error
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = async (id) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post(`/tokens/reset/${id}`)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '重置token失败';
|
||||||
|
throw error
|
||||||
|
}finally{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateToken = async (token) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.put(`/tokens/${token.id}`, token)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '更新token失败';
|
||||||
|
throw error
|
||||||
|
}finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteToken = async (id) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.delete(`/tokens/${id}`)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '删除token失败';
|
||||||
|
throw err
|
||||||
|
}finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
user.value = null
|
||||||
|
token.value = ''
|
||||||
|
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clear()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,error,
|
||||||
|
user,role,token,
|
||||||
|
isLoggedIn,
|
||||||
|
setToken,loadTokenFromStorage,
|
||||||
|
login,register,
|
||||||
|
getProfile,updateProfile,updatePassword,refreshProfile,
|
||||||
|
createToken,deleteToken,resetToken,updateToken,
|
||||||
|
clear,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
});
|
||||||
140
frontend/src/stores/key.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// src/stores/key.js
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export const useKeyStore = defineStore('key', () => {
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const totalKeys = ref(0);
|
||||||
|
const keys = ref([]);
|
||||||
|
const key = ref(null);
|
||||||
|
|
||||||
|
const fetchKeys = async (pageSize = 20, page = 1, active) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get('/keys', {
|
||||||
|
params: {
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
keys.value = response.data.data?.keys;
|
||||||
|
totalKeys.value = response.data.data?.total;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取ApiKeys失败';
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchKey = async (id) => {
|
||||||
|
if (keys.value.length > 0) {
|
||||||
|
const findkey = keys.value.find(item => item.id === id)
|
||||||
|
if (findkey) {
|
||||||
|
key.value = findkey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
// const findkey = keys.find(item=>item.id === id)
|
||||||
|
// console.log('findkey',findkey)
|
||||||
|
try {
|
||||||
|
const response = await request.get(`/keys/${id}`);
|
||||||
|
key.value = response.data.data;
|
||||||
|
if (key.value.support_models.length < 3) {
|
||||||
|
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
|
||||||
|
}
|
||||||
|
if (!key.value.support_models_array) {
|
||||||
|
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取ApiKey失败';
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshKey = async (id) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get(`/keys/${id}`);
|
||||||
|
key.value = response.data.data;
|
||||||
|
if (key.value.support_models.length < 3) {
|
||||||
|
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
|
||||||
|
}
|
||||||
|
if (!key.value.support_models_array) {
|
||||||
|
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取ApiKey失败';
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createKey = async (data) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post('/keys', data);
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '创建ApiKey失败';
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKey = async (key) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.put(`/keys/${key.id}`, key);
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '更新ApiKey失败';
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyOption = async (option, ids) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post(`/keys/batch/${option}`, { ids });
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '操作失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//todo 更新 批量操作
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, error,
|
||||||
|
key, keys, totalKeys,
|
||||||
|
fetchKeys,
|
||||||
|
fetchKey,
|
||||||
|
refreshKey,
|
||||||
|
createKey,
|
||||||
|
updateKey,
|
||||||
|
keyOption,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
138
frontend/src/stores/user.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// src/stores/user.js
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const users = ref([]);
|
||||||
|
const totalUsers = ref(0);
|
||||||
|
const user = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function createUser(userData) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post('/users', userData);
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '创建用户失败'
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUser(pageSize = 20, page = 1, active) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get('/users', {
|
||||||
|
params: {
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
users.value = response.data.data?.users;
|
||||||
|
totalUsers.value = response.data.data?.total;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取用户列表失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(id) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get(`/users/${id}`);
|
||||||
|
console.log('getUser response',response);
|
||||||
|
user.value = response.data.data;
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取用户信息失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUser(id) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get(`/users/${id}`);
|
||||||
|
console.log('getUser response',response);
|
||||||
|
user.value = response.data.data;
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取用户信息失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editUser(id, userData) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response= await request.put(`/users/${id}`, userData);
|
||||||
|
console.log('editUser',response);
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '编辑用户失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.delete(`/users/${id}`);
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '删除用户失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userOption(option, ids) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.post(`/users/batch/${option}`, { ids });
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '操作失败';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
totalUsers,
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createUser,
|
||||||
|
listUser,
|
||||||
|
getUser,
|
||||||
|
refreshUser,
|
||||||
|
editUser,
|
||||||
|
deleteUser,
|
||||||
|
userOption,
|
||||||
|
};
|
||||||
|
});
|
||||||
138
frontend/src/stores/webauth.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import request from "@/utils/request";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
|
export const useWebAuthStore = defineStore("webauth", () => {
|
||||||
|
const router = useRouter();
|
||||||
|
// const token = ref(localStorage.getItem("token") || "");
|
||||||
|
|
||||||
|
const passkeys = ref(null);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
const addPasskey = async () => {
|
||||||
|
error.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 1. 从后端获取注册选项 (Creation Options)
|
||||||
|
const res = await request.get("/profile/passkey");
|
||||||
|
// console.log("begin:", res.data.data.publicKey);
|
||||||
|
const options = res.data.data.publicKey;
|
||||||
|
|
||||||
|
// 调用 Web Authentication API 进行注册
|
||||||
|
// const credential = await navigator.credentials.create(options);
|
||||||
|
// console.log("credential:", credential);
|
||||||
|
let attestation;
|
||||||
|
try {
|
||||||
|
// Pass 'undefined' as the second argument if you are not using an AbortSignal
|
||||||
|
attestation = await startRegistration({optionsJSON: options});
|
||||||
|
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
|
||||||
|
error.value = null;
|
||||||
|
} catch (regError) {
|
||||||
|
// console.log("WebAuthn 注册失败或取消:", regError);
|
||||||
|
if (regError.name === "NotAllowedError") {
|
||||||
|
error.value = "Passkey 操作被取消或不允许。";
|
||||||
|
} else {
|
||||||
|
error.value = `Passkey 创建出错: ${regError.message}`;
|
||||||
|
}
|
||||||
|
return; // 终止流程
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
|
||||||
|
const res2 = await request.post("/profile/passkey", attestation);
|
||||||
|
// console.log("end:", res2);
|
||||||
|
return res2;
|
||||||
|
} catch (err) {
|
||||||
|
error.value =err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginPasskey = async () => {
|
||||||
|
error.value = null;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 1. 从后端获取登录选项 (Assertion Options)
|
||||||
|
const res = await request.get("/auth/passkey/begin");
|
||||||
|
// console.log("login begin:", res.data);
|
||||||
|
const options = res.data.data.publicKey;
|
||||||
|
|
||||||
|
// 2. 调用 Web Authentication API 进行认证
|
||||||
|
let assertion;
|
||||||
|
try {
|
||||||
|
assertion = await startAuthentication({ optionsJSON: options });
|
||||||
|
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
|
||||||
|
} catch (loginError) {
|
||||||
|
if (loginError.name === "NotAllowedError") {
|
||||||
|
error.value = "Passkey 登录被取消或不允许。";
|
||||||
|
} else {
|
||||||
|
error.value = `Passkey 登录出错: ${loginError.message}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
|
||||||
|
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
|
||||||
|
const res2 = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
|
||||||
|
|
||||||
|
// 4. 处理登录成功的响应,通常包含 Token
|
||||||
|
if (res2.status === 200 && !!res2.data.data?.token) {
|
||||||
|
const token = res2.data.data.token
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setToken(token)
|
||||||
|
await authStore.getProfile()
|
||||||
|
return res2.data
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || err.value || "Passkey 登录失败,请稍后重试。";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasskeys = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get('/profile/passkeys')
|
||||||
|
// console.log('getPasskeys',response.data.data)
|
||||||
|
passkeys.value = response.data.data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || '获取token列表失败';
|
||||||
|
throw error
|
||||||
|
}finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePasskey = async (id) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.delete(`/profile/passkeys/${id}`)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
|
||||||
|
throw error
|
||||||
|
}finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passkeys,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
addPasskey,
|
||||||
|
loginPasskey,
|
||||||
|
getPasskeys,
|
||||||
|
deletePasskey,
|
||||||
|
};
|
||||||
|
});
|
||||||
11
frontend/src/style.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
32
frontend/src/utils/format-date.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// src/utils/format-date.js
|
||||||
|
|
||||||
|
export function dateToUnix(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return Math.floor(date.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unixToDate(timestamp) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(unixTimestamp) {
|
||||||
|
// 如果时间戳不存在或为0,返回'未知'
|
||||||
|
if (!unixTimestamp) return "未知";
|
||||||
|
|
||||||
|
// 将Unix时间戳转换为毫秒
|
||||||
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
|
||||||
|
// 获取日期和时间的各个部分
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
|
// 返回格式化的日期时间字符串
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
57
frontend/src/utils/request.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// src/utils/request.js
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_API_BASE_URL|| '/api'
|
||||||
|
if (import.meta.env.DEV) { // Vite 的方式判断开发环境
|
||||||
|
console.log(`[Request] API Base URL: ${baseURL}`);
|
||||||
|
} else if (process.env.NODE_ENV === 'development') { // Vue CLI 的方式判断开发环境
|
||||||
|
console.log(`[Request] API Base URL: ${baseURL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = axios.create({
|
||||||
|
baseURL: baseURL,
|
||||||
|
timeout: 6000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
service.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (!authStore.token) {
|
||||||
|
authStore.loadTokenFromStorage();
|
||||||
|
}
|
||||||
|
if (authStore.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
console.error('Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
service.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
return response; // 只返回响应数据,便于后续使用
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
// 可以在这里处理响应错误的情况,例如统一处理错误信息, 提示用户等
|
||||||
|
console.error('Response error:', error);
|
||||||
|
// 这里可以做一些统一的错误处理,例如根据状态码判断是否 token 失效,并跳转到登录页面
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
authStore.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default service;
|
||||||
75
frontend/src/utils/router_menu.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ShieldPlus,
|
||||||
|
UsersRoundIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
CommandIcon,
|
||||||
|
BracesIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{ path: '/', name: 'Home',component: () => import('@/views/Home.vue') },
|
||||||
|
{ path: '/404', name: '404',component: () => import('@/views/404.vue') },
|
||||||
|
|
||||||
|
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
|
||||||
|
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') },
|
||||||
|
|
||||||
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue')}, // Catch all 404
|
||||||
|
{ path: '/dashboard', name: 'Dashboard', component: ()=>import('@/views/DashBoard.vue'), meta: { requiresAuth: true, title: 'Dashboard', showInSidebar: false },redirect: '/dashboard/overview', children:[
|
||||||
|
{ path: 'overview', name: 'Overview', component: ()=>import('@/views/dashboard/Overview.vue'),meta: { title: 'Overview', icon: LayoutDashboardIcon, showInSidebar: true } },
|
||||||
|
{ path: 'tokens', name: 'Tokens', component: ()=>import('@/views/dashboard/Tokens.vue'),meta: { title: 'Tokens', icon: BracesIcon, showInSidebar: true } },
|
||||||
|
{ path: 'manager', name: 'Manager',meta: { title: 'Manager', icon: CommandIcon, showInSidebar: true, open: true, badge: 'Admin' }, redirect: '/dashboard/manager/users',children:[
|
||||||
|
{ path: 'users', name: 'User', component: ()=>import('@/views/dashboard/User.vue'),meta: { title: 'Users', icon: UsersRoundIcon, showInSidebar: true } },
|
||||||
|
{ path: 'users/new', name: 'UserNew', component: ()=>import('@/views/dashboard/UserNew.vue'),meta: { title: 'UserNew', icon: UsersRoundIcon, showInSidebar: false } },
|
||||||
|
{ path: 'users/view', name: 'UserView', component: ()=>import('@/views/dashboard/UserView.vue'),meta: { title: 'UserView', icon: UsersRoundIcon, showInSidebar: false } },
|
||||||
|
{ path: 'keys', name: 'ApiKey', component: ()=>import('@/views/dashboard/Keys.vue') ,meta: { title: 'ApiKeys', icon: KeyRoundIcon, showInSidebar: true } },
|
||||||
|
{ path: 'keys/view', name: 'ApiKeyView', component: ()=>import('@/views/dashboard/KeyView.vue') ,meta: { title: 'ApiKeyView', icon: KeyRoundIcon, showInSidebar: false } },
|
||||||
|
] },
|
||||||
|
{ path: 'settings', name: 'Settings', meta: { title: 'Settings', icon: SettingsIcon, showInSidebar: true, open: false } , redirect: '/dashboard/settings/profile',children:[
|
||||||
|
{ path: 'profile', name: 'Profile', component: ()=>import('@/views/dashboard/Profile.vue'),meta: { title: 'Profile', icon: UserIcon, showInSidebar: true } },
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateMenuItemsFromRoutes(routes, userRole, parentPath = '') {
|
||||||
|
const menuItems = [];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
if (route.meta && route.meta.title && route.meta.showInSidebar) {
|
||||||
|
const fullPath = parentPath + '/' + route.path.replace(/^\//, '');
|
||||||
|
const menuItem = {
|
||||||
|
label: route.meta.title,
|
||||||
|
to: fullPath,
|
||||||
|
icon: route.meta.icon,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
if (route.name === 'Manager' && userRole < 10) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
menuItem.type = 'submenu';
|
||||||
|
menuItem.open = route.meta.open !== undefined ? route.meta.open : false;
|
||||||
|
menuItem.badge = route.meta.badge;
|
||||||
|
menuItem.children = generateMenuItemsFromRoutes(route.children, userRole, fullPath);
|
||||||
|
} else {
|
||||||
|
menuItem.type = 'link';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.name === 'Overview') {
|
||||||
|
menuItems.push(menuItem);
|
||||||
|
menuItems.push({ type: 'title', label: 'Apps' });
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
menuItems.push(menuItem);
|
||||||
|
} else if (route.path === '/dashboard' && route.children) {
|
||||||
|
|
||||||
|
menuItems.push(...generateMenuItemsFromRoutes(route.children, userRole, '/dashboard'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuItems;
|
||||||
|
}
|
||||||
65
frontend/src/views/404.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 text-base-content">
|
||||||
|
<header class="fixed w-full top-0 z-50 backdrop-blur-md bg-base-100/50">
|
||||||
|
<div class="container mx-auto flex justify-between items-center p-4">
|
||||||
|
<div class="flex items-center h-12 w-12 rounded-full text-l">
|
||||||
|
<img src="../assets/logo.svg" alt="Logo" class="select-none">
|
||||||
|
<span class="hidden sm:flex text-xl font-bold">
|
||||||
|
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-grow flex flex-col justify-center items-center pt-16">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="flex items-center justify-center my-8 outline-none select-none">
|
||||||
|
<!-- <img src="../assets/404.svg" alt="404 Not Found" class="h-48"> -->
|
||||||
|
</div>
|
||||||
|
<h1 class="text-5xl font-bold mb-4 text-rose-300">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-400 text-lg max-w-lg mx-auto mb-8">
|
||||||
|
迷路了吗?<span class="line-through text-gray-200">卑鄙的</span>异乡客?
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<a href="/" class="btn btn-primary">Go Back Home</a>
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" class="btn btn-outline">Visit GitHub Repo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer footer-center text-base-content rounded p-10 mt-16 absolute bottom-0 w-full">
|
||||||
|
<nav>
|
||||||
|
<div class="grid grid-flow-col gap-4">
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<aside>
|
||||||
|
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
|
||||||
|
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
|
||||||
|
</aside>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const currentYear = ref('');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
currentYear.value = new Date().getFullYear().toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* You can add specific styles for the 404 page here if needed */
|
||||||
|
</style>
|
||||||
160
frontend/src/views/DashBoard.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!-- src/layouts/MainLayout.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100">
|
||||||
|
<div class="drawer drawer-overlay" :class="{ 'md:drawer-open': isLargeSidebarOpen }">
|
||||||
|
<!-- Sidebar Toggle for Mobile -->
|
||||||
|
<input id="drawer" type="checkbox" class="drawer-toggle" />
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="drawer-content flex flex-col">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<header class="w-full navbar bg-base-100 border-b border-base-200 h-14 min-h-[3rem]">
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<div class="flex-none">
|
||||||
|
<div class="md:hidden">
|
||||||
|
<label for="drawer" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
|
||||||
|
<MenuIcon class="w-5 h-5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Desktop menu button -->
|
||||||
|
<div class="hidden md:flex">
|
||||||
|
<label @click="toggleSidebarLarge" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
|
||||||
|
<MenuIcon class="w-5 h-5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-auto space-x-1 justify-end">
|
||||||
|
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem]" @click="toggleTheme">
|
||||||
|
<SunIcon v-if="isDark" class="w-5 h-5" />
|
||||||
|
<MoonIcon v-else class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem] hidden">
|
||||||
|
<div class="indicator">
|
||||||
|
<BellIcon class="w-5 h-5" />
|
||||||
|
<span class="badge badge-xs badge-primary indicator-item"></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown dropdown-bottom dropdown-end">
|
||||||
|
<label tabindex="0" class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20 h-10 min-h-[2.5rem]">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 有头像时显示头像 -->
|
||||||
|
<div v-if="userInfo.avatar" class="avatar">
|
||||||
|
<div class="mask mask-squircle w-8 h-8">
|
||||||
|
<img :src="userInfo.avatar" :alt="userInfo.name">
|
||||||
|
<!-- <img src='../assets/logo.svg' :alt="userInfo.name"> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 没有头像时显示首字母 -->
|
||||||
|
<div v-else class="avatar placeholder">
|
||||||
|
<div class="bg-neutral text-neutral-content w-8 mask mask-squircle">
|
||||||
|
<span class="text-sm">{{ userInitials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<p class="text-sm/none">{{ userInfo.username }}</p>
|
||||||
|
<p class="mt-1 text-xs/none text-primary">Edit</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<ul class="menu menu-sm dropdown-content mt-2 z-[1] p-1.5 shadow bg-base-100 rounded-box w-32">
|
||||||
|
<template v-for="item in userNavigation" :key="item.name || 'divider'">
|
||||||
|
<hr v-if="item.type === 'divider'" class="-mx-2 my-1 border-base-content/10">
|
||||||
|
<li v-else :class="item.class">
|
||||||
|
<a href="#" class="py-1.5" @click.prevent="handleNavigation(item)">
|
||||||
|
<component :is="item.icon" class="w-4 h-4" />
|
||||||
|
{{ item.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="flex-1 overflow-y-auto p-6 bg-base-200 px-1 sm:px-6">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="drawer-side">
|
||||||
|
<label for="drawer" class="drawer-overlay"></label>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, onMounted } from 'vue'
|
||||||
|
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, Settings, LogOut } from 'lucide-vue-next'
|
||||||
|
import Sidebar from '@/components/dashboard/Sidebar.vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isDark = ref(false)
|
||||||
|
const isLargeSidebarOpen = ref(true)
|
||||||
|
|
||||||
|
const userInfo = computed(() => {
|
||||||
|
return authStore.user || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userInfo) {
|
||||||
|
await authStore.getProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
// If name exists, use it first
|
||||||
|
if (userInfo.value.name) {
|
||||||
|
return userInfo.value.name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2); // Max 2 letters
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.value.username) {
|
||||||
|
return userInfo.value.username
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2); // Max 2 letters
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'U';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userNavigation = reactive([
|
||||||
|
{ name: 'Profile', icon: User },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ name: 'Logout', icon: LogOut, class: 'text-error' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleNavigation = (item) => {
|
||||||
|
if (item.name === 'Profile') {
|
||||||
|
router.push('/dashboard/settings/profile')
|
||||||
|
} else if (item.name === 'Logout'){
|
||||||
|
authStore.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebarLarge = () => {
|
||||||
|
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'emerald')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
154
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 text-base-content">
|
||||||
|
<div class="navbar fixed w-full top-0 z-50 backdrop-blur-sm bg-base-100/50">
|
||||||
|
<div class="container mx-auto flex justify-between items-center p-1 rounded-box">
|
||||||
|
<div class="flex items-center h-12 w-12 rounded-full text-l">
|
||||||
|
<img src="../assets/logo.svg" alt="Logo" class="select-none">
|
||||||
|
<span class="hidden sm:flex text-xl font-bold">
|
||||||
|
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="btn btn-md h-10 min-h-10 px-3 md:flex bg-black text-white items-center justify-center whitespace-nowrap rounded-xl text-sm font-extralight ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-black/90 flex gap-2">
|
||||||
|
<Icon icon="simple-icons:github" class="size-5" />
|
||||||
|
<span>Star on GitHub</span>
|
||||||
|
<Icon icon="mingcute:star-fill" class="size-5 text-yellow-500 mb-0.5" />
|
||||||
|
<div class="font-extralight">{{ star }}</div>
|
||||||
|
</a>
|
||||||
|
<a class="hidden btn btn-outline font-extralight btn-md h-10 min-h-10 hover:bg-black px-1 ml-2"
|
||||||
|
@click="$router.push('/dashboard')">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="flex-grow flex flex-col justify-center items-center pt-16">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="flex items-center justify-center my-4 outline-none select-none">
|
||||||
|
<img src="../assets/openteam.png" alt="Project Logo" class="h-40">
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-bold mb-4">
|
||||||
|
<a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg max-w-2xl mx-auto mb-8">
|
||||||
|
OpenTeam is an open-source, team-shared service. that is compatible with OpenAI API and can
|
||||||
|
be safely shared with others for API usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 md:tooltip md:tooltip-left md:tooltip-open" data-tip="指向 OpenTeam 的 base_url">
|
||||||
|
<div class="join">
|
||||||
|
<input type="text" class="input input-bordered join-item grow focus:outline-none rounded-l-full"
|
||||||
|
v-model="url" />
|
||||||
|
<button class="hover:bg-black hover:text-white btn join-item rounded-r-full" @click="copyUrl">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto px-4 text-center">
|
||||||
|
<p class="text-sm mb-4">
|
||||||
|
👉Api-Keys: <a href="https://platform.openai.com/account/api-keys"
|
||||||
|
class="link">https://platform.openai.com/account/api-keys</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div class="card-body px-1 flex flex-col items-center">
|
||||||
|
<h2 class="card-title text-xl font-extralight justify-center flex mb-4 p-2 rounded-lg border-2 border-dotted hover:cursor-alias"
|
||||||
|
@click="$router.push('/dashboard')">开始使用</h2>
|
||||||
|
|
||||||
|
<p class="mb-0">使用OpenTeam 管理你的LLM API,仅需一个地址即可接入不同的大模型</p>
|
||||||
|
<LineSegmentFlow />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card backdrop-blur-sm shadow-xl mb-8">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2">欢迎加入我们的Telegram频道,获取最新动态和帮助</p>
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel">
|
||||||
|
<img src="../assets/openteam_channel.jpg" alt="Telegram Group QR Code"
|
||||||
|
class="w-40 fill-current backdrop-blur-0 select-none">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer footer-center text-base-content rounded p-10">
|
||||||
|
<nav>
|
||||||
|
<div class="grid grid-flow-col gap-4">
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<aside>
|
||||||
|
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
|
||||||
|
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
|
||||||
|
</aside>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, inject } from 'vue';
|
||||||
|
import LineSegmentFlow from '@/components/LineSegmentFlow.vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
|
||||||
|
const currentYear = ref('');
|
||||||
|
const url = ref('');
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url.value);
|
||||||
|
setToast('复制成功!', 'info');
|
||||||
|
} catch (err) {
|
||||||
|
setToast('复制失败,请手动复制。', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const star = ref(0);
|
||||||
|
const getGithubStars = async () => {
|
||||||
|
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam', { next: { revalidate: 3600 } });
|
||||||
|
const data = await res.json();
|
||||||
|
return data.repo.stars;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
url.value = window.location.origin;
|
||||||
|
currentYear.value = new Date().getFullYear().toString();
|
||||||
|
|
||||||
|
getGithubStars().then((stars) => {
|
||||||
|
star.value = stars;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast {
|
||||||
|
transition: opacity 0.3s, visibility 0.3s;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
/* 调整这个值以匹配你的 header 高度 */
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 0 1em;
|
||||||
|
border-left: 0.25em solid #838989aa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<img src="../assets/openteam.webp" alt="Company Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer"
|
||||||
|
@click="$router.push('/')" />
|
||||||
|
|
||||||
|
<h2 class="card-title text-md sm:text-xl mb-6 justify-center flex">
|
||||||
|
Log in to Dashboard
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="username">
|
||||||
|
<span class="label-text">Account</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="username" placeholder="username/email" class="input input-bordered w-full input-sm"
|
||||||
|
v-model="user.username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="password">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
<a href="#" class="label-text-alt link link-hover link-primary text-sm">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" placeholder="••••••••" class="input input-bordered w-full input-sm"
|
||||||
|
v-model="user.password" minlength="4" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-6">
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
|
||||||
|
<span class="label-text text-sm">Remember me</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<button type="submit" class="btn btn-outline w-full btn-sm">
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="error = null">X</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider my-2 text-sm">OR</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-6">
|
||||||
|
<button @click="handlePasskeyLogin" class="btn btn-outline w-full btn-sm" :disabled="!supportWebAuth">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
|
||||||
|
</svg>
|
||||||
|
Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="hidden flex flex-col sm:flex-row gap-3 w-full">
|
||||||
|
<button @click="handleGithubLogin" class="btn btn-outline flex-1 btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-github mr-1"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
<button @click="handleGoogleLogin" class="btn btn-outline flex-1 btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-google mr-1"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M15.545 6.558a9.4 9.4 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0a7.7 7.7 0 0 1 5.352 2.082l-2.284 2.284A4.35 4.35 0 0 0 8 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.8 4.8 0 0 0 0 3.063h.003c.635 1.896 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.7 3.7 0 0 0 1.599-2.431H8v-3.08z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
<button @click="handleTelegramLogin" class="btn btn-outline flex-1 btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-telegram mr-1" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8.287 5.906q-1.168.486-4.666 2.01-.567.225-.595.442c-.03.243.275.339.69.47l.175.055c.408.133.958.288 1.243.294q.39.01.868-.32 3.269-2.206 3.374-2.23c.05-.012.12-.026.166.016s.042.12.037.141c-.03.129-1.227 1.241-1.846 1.817-.193.18-.33.307-.358.336a8 8 0 0 1-.188.186c-.38.366-.664.64.015 1.088.327.216.589.393.85.571.284.194.568.387.936.629q.14.092.27.187c.331.236.63.448.997.414.214-.02.435-.22.547-.82.265-1.417.786-4.486.906-5.751a1.4 1.4 0 0 0-.013-.315.34.34 0 0 0-.114-.217.53.53 0 0 0-.31-.093c-.3.005-.763.166-2.984 1.09" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-6 text-sm">
|
||||||
|
Don't have an account?
|
||||||
|
<router-link to="/signup" class="link link-primary link-hover">
|
||||||
|
Sign up
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, inject, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useWebAuthStore } from '@/stores/webauth';
|
||||||
|
// import request from '@/utils/request';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const webauthStore = useWebAuthStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const error = ref(null)
|
||||||
|
const user = reactive({
|
||||||
|
username: localStorage.getItem('account') || '',
|
||||||
|
password: localStorage.getItem('password') || '',
|
||||||
|
rember: localStorage.getItem('rember') || false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportWebAuth = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
supportWebAuth.value = !!window.PublicKeyCredential;
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await authStore.login({ username: user.username, password: user.password });
|
||||||
|
if (response.status === 200) {
|
||||||
|
if (user.rember) {
|
||||||
|
localStorage.setItem('account', user.username);
|
||||||
|
localStorage.setItem('password', user.password);
|
||||||
|
localStorage.setItem('rember', user.rember);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('account');
|
||||||
|
localStorage.removeItem('password');
|
||||||
|
localStorage.removeItem('rember');
|
||||||
|
}
|
||||||
|
setToast('登录成功', 'success');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
error.value = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await webauthStore.loginPasskey();
|
||||||
|
if (!!res.code && res.code === 200) {
|
||||||
|
setToast('登录成功', 'success');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Passkey login error:', err);
|
||||||
|
error.value = err
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGithubLogin = () => {
|
||||||
|
alert('GitHub login is not yet implemented.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
alert('Google login is not yet implemented.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTelegramLogin = () => {
|
||||||
|
alert('Telegram login is not yet implemented.');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
93
frontend/src/views/Signup.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<img src="../assets/openteam.webp" alt="Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer" @click="$router.push('/')"/>
|
||||||
|
|
||||||
|
<h2 class="card-title text-md sm:text-xl mb-2 justify-center flex">
|
||||||
|
Create Your Account
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="account">
|
||||||
|
<span class="label-text">Account</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="account" placeholder="username"
|
||||||
|
class="input input-bordered w-full input-sm" v-model="username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="password">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<input id="password" type="password" placeholder="password"
|
||||||
|
class="input input-bordered w-full input-sm" v-model="password" required minlength="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-6">
|
||||||
|
<label class="label" for="confirm-password">
|
||||||
|
<span class="label-text">Confirm Password</span>
|
||||||
|
</label>
|
||||||
|
<input type="password" id="confirm-password" placeholder="Retype your password"
|
||||||
|
class="input input-bordered w-full input-sm" v-model="confirmPassword" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<button type="submit" class="btn btn-outline w-full btn-sm">
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="text-error text-sm mt-2 text-center">{{ error }}</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="text-center mt-4 text-sm">
|
||||||
|
Already have an account?
|
||||||
|
<router-link to="/login" class="link link-primary link-hover">
|
||||||
|
Login
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
alert("密码不一致");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let res = await authStore.register({
|
||||||
|
username: username.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
if (res.status === 200) {
|
||||||
|
setToast(res.data?.msg || '已注册', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, 100);
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Registration failed. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
315
frontend/src/views/dashboard/KeyNew.vue
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="mb-6 text-center md:text-left">
|
||||||
|
<h1 class="text-2xl font-semibold text-base-content">
|
||||||
|
Create New API Key
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
Enter the API key's details below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300/40 shadow-sm">
|
||||||
|
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="newApiKey.name" placeholder="API Key Name"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="apitype" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Type <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select id="apitype" v-model="newApiKey.type" class="select select-sm select-bordered w-full pl-10"
|
||||||
|
required>
|
||||||
|
<option class="disabled" value="" selected>Select API Type</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="claude">Claude</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="azure">Azure</option>
|
||||||
|
<option value="github">Github</option>
|
||||||
|
<option value="openai-compatible">OpenAI Compatible</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
|
||||||
|
id="password-visibility-toggle">
|
||||||
|
<img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt="">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="apikey" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
API Key <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="apikey" type="text" v-model="newApiKey.apikey" placeholder="Your API Key"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="endpoint" class="label">
|
||||||
|
<span class="label-text">Endpoint</span>
|
||||||
|
</label>
|
||||||
|
<input id="endpoint" type="text" v-model="newApiKey.endpoint" placeholder="API Endpoint URL"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-0">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="resource_name" class="label">
|
||||||
|
<span class="label-text">Resource Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="resource_name" type="text" v-model="newApiKey.resource_name" placeholder="Resource Name"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="form-control">
|
||||||
|
<label for="deployment_name" class="label">
|
||||||
|
<span class="label-text">Deployment Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
|
||||||
|
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="api_secret" class="label">
|
||||||
|
<span class="label-text">API Secret</span>
|
||||||
|
</label>
|
||||||
|
<input id="api_secret" type="text" v-model="newApiKey.api_secret" placeholder="API Secret"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="model_prefix" class="label">
|
||||||
|
<span class="label-text">Model Prefix</span>
|
||||||
|
</label>
|
||||||
|
<input id="model_prefix" type="text" v-model="newApiKey.model_prefix" placeholder="Model Prefix"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="model_alias" class="label">
|
||||||
|
<span class="label-text">Model Alias</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="model_alias" v-model="newApiKey.model_alias" placeholder="{}"
|
||||||
|
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="parameters" class="label">
|
||||||
|
<span class="label-text">Parameters (JSON)</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="parameters" v-model="newApiKey.parameters" placeholder="{}"
|
||||||
|
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control">
|
||||||
|
<label for="support_models" class="label">
|
||||||
|
<span class="label-text">Support Models</span>
|
||||||
|
</label>
|
||||||
|
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
|
||||||
|
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
||||||
|
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
|
||||||
|
placeholder="Please input" @change="onchange_supportmodel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3 h-9">
|
||||||
|
<input type="checkbox" v-model="newApiKey.active"
|
||||||
|
:class="`toggle toggle-sm ${newApiKey.active ? 'toggle-success' : 'toggle-error'}`" />
|
||||||
|
<span class="text-sm text-base-content/90">
|
||||||
|
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="error = null">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4 items-center gap-2">
|
||||||
|
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success"
|
||||||
|
:disabled="!isFormValid">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, inject } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useKeyStore } from '@/stores/key';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const keyStore = useKeyStore()
|
||||||
|
const { setToast } = inject('toast')
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// Control advanced options visibility
|
||||||
|
const showAdvancedOptions = ref(false)
|
||||||
|
|
||||||
|
// Initialize API key object
|
||||||
|
const newApiKey = ref({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
apikey: '',
|
||||||
|
active: true,
|
||||||
|
endpoint: '',
|
||||||
|
resource_name: '',
|
||||||
|
// deployment_name: '',
|
||||||
|
api_secret: '',
|
||||||
|
model_prefix: '',
|
||||||
|
model_alias: '',
|
||||||
|
parameters: '{}',
|
||||||
|
support_models: '[]',
|
||||||
|
support_models_array: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetNewApiKey = () => {
|
||||||
|
newApiKey.value = {
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
apikey: '',
|
||||||
|
active: true,
|
||||||
|
endpoint: '',
|
||||||
|
resource_name: '',
|
||||||
|
// deployment_name: '',
|
||||||
|
api_secret: '',
|
||||||
|
model_prefix: '',
|
||||||
|
model_alias: '',
|
||||||
|
parameters: '{}',
|
||||||
|
support_models: '[]',
|
||||||
|
support_models_array: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onchange_supportmodel = () => {
|
||||||
|
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return newApiKey.value.name &&
|
||||||
|
newApiKey.value.type &&
|
||||||
|
newApiKey.value.apikey
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
resetNewApiKey()
|
||||||
|
emit('closeModal', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyImageMap = {
|
||||||
|
'openai': '/assets/openai.svg',
|
||||||
|
'claude': '/assets/claude.svg',
|
||||||
|
'gemini': '/assets/gemini.svg',
|
||||||
|
'azure': '/assets/azure.svg',
|
||||||
|
'github': '/assets/github.svg'
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKeyImageUrl = (keytype) => {
|
||||||
|
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const createApiKey = async () => {
|
||||||
|
if (!isFormValid.value) {
|
||||||
|
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||||
|
setToast('Support Models must be a JSON array.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setToast('Invalid JSON format for Support Models.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to parse parameters JSON
|
||||||
|
try {
|
||||||
|
JSON.parse(newApiKey.value.parameters);
|
||||||
|
} catch (e) {
|
||||||
|
setToast('Invalid JSON format for Parameters.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await keyStore.createKey(newApiKey.value);
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
error.value = null;
|
||||||
|
resetNewApiKey();
|
||||||
|
setToast('API Key created successfully', 'success')
|
||||||
|
// Optionally navigate or reset form
|
||||||
|
emit('closeModal', true)
|
||||||
|
} else {
|
||||||
|
setToast(res.error || res.data?.message || 'Failed to create API Key', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('createApiKey error:', err)
|
||||||
|
error.value = err || 'Failed to create API Key'
|
||||||
|
// setToast(error.response?.data?.error || 'Failed to create API Key', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const emit = defineEmits(['closeModal'])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Minimal custom styles if absolutely necessary */
|
||||||
|
.collapse .collapse-title {
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
231
frontend/src/views/dashboard/KeyView.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader title="Key Details" />
|
||||||
|
|
||||||
|
<div class="divider mt-1 mb-0"></div>
|
||||||
|
<div class="card border border-base-300/40 shadow-sm"v-if="key" >
|
||||||
|
<form @submit.prevent="updateKey" class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="key.name" placeholder="API Key Name"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="apitype" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Type <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select id="apitype" v-model="key.type" class="select select-sm select-bordered w-full pl-10"
|
||||||
|
required>
|
||||||
|
<option class="disabled" value="" selected>Select API Type</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="claude">Claude</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="azure">Azure</option>
|
||||||
|
<option value="github">Github</option>
|
||||||
|
<option value="openai-compatible">OpenAI Compatible</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
|
||||||
|
id="password-visibility-toggle">
|
||||||
|
<img :src="apiKeyImageUrl(key.type)" class="w-5 h-5" alt="">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="apikey" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
API Key <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="apikey" type="text" v-model="key.apikey" placeholder="Your API Key"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="endpoint" class="label">
|
||||||
|
<span class="label-text">Endpoint</span>
|
||||||
|
</label>
|
||||||
|
<input id="endpoint" type="text" v-model="key.endpoint" placeholder="API Endpoint URL"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" checked class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-0">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="resource_name" class="label">
|
||||||
|
<span class="label-text">Resource Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="resource_name" type="text" v-model="key.resource_name" placeholder="Resource Name"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="form-control">
|
||||||
|
<label for="deployment_name" class="label">
|
||||||
|
<span class="label-text">Deployment Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="deployment_name" type="text" v-model="key.deployment_name"
|
||||||
|
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="api_secret" class="label">
|
||||||
|
<span class="label-text">API Secret</span>
|
||||||
|
</label>
|
||||||
|
<input id="api_secret" type="text" v-model="key.api_secret" placeholder="API Secret"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="model_prefix" class="label">
|
||||||
|
<span class="label-text">Model Prefix</span>
|
||||||
|
</label>
|
||||||
|
<input id="model_prefix" type="text" v-model="key.model_prefix" placeholder="Model Prefix"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="model_alias" class="label">
|
||||||
|
<span class="label-text">Model Alias</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="model_alias" v-model="key.model_alias" placeholder="{}"
|
||||||
|
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="parameters" class="label">
|
||||||
|
<span class="label-text">Parameters (JSON)</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="parameters" v-model="key.parameters" placeholder="{}"
|
||||||
|
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control">
|
||||||
|
<label for="support_models" class="label">
|
||||||
|
<span class="label-text">Support Models</span>
|
||||||
|
</label>
|
||||||
|
<!-- <textarea id="support_models" v-model="key.support_models_text"
|
||||||
|
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
||||||
|
<el-input-tag v-model="key.support_models_array" :trigger="'Enter'" clearable
|
||||||
|
placeholder="Please input" @change="onchange_supportmodel"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3 h-9">
|
||||||
|
<input type="checkbox" v-model="key.active"
|
||||||
|
:class="`toggle toggle-sm ${key.active ? 'toggle-success' : 'toggle-error'}`" />
|
||||||
|
<span class="text-sm text-base-content/90">
|
||||||
|
{{ key.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4 items-center gap-2">
|
||||||
|
<button @click="cancel" class="btn btn-sm btn-outline">Back</button>
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, inject, reactive } from 'vue';
|
||||||
|
import { useRoute,useRouter } from 'vue-router';
|
||||||
|
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next';
|
||||||
|
import { useKeyStore } from '../../stores/key';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const keyStore = useKeyStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const keyId = computed(() => route.query.id);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = computed(() => keyStore.key);
|
||||||
|
const loading = computed(() => keyStore.loading);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
console.log('keyId', keyId.value)
|
||||||
|
if (keyId.value) {
|
||||||
|
await keyStore.fetchKey(keyId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyOption = reactive([
|
||||||
|
{name: 'openai', label: 'OpenAI'},
|
||||||
|
{name: 'claude', label: 'Claude'},
|
||||||
|
{name: 'gemini', label: 'Gemini'},
|
||||||
|
{name: 'azure', label: 'Azure'},
|
||||||
|
{name: 'github', label: 'Github'},
|
||||||
|
{name: 'openai-compatible', label: 'OpenAI Compatible'}
|
||||||
|
])
|
||||||
|
|
||||||
|
const apiKeyImageMap = {
|
||||||
|
'openai': '/assets/openai.svg',
|
||||||
|
'claude': '/assets/claude.svg',
|
||||||
|
'gemini': '/assets/gemini.svg',
|
||||||
|
'azure': '/assets/azure.svg',
|
||||||
|
'github': '/assets/github.svg'
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKeyImageUrl = (keytype) => {
|
||||||
|
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onchange_supportmodel = () => {
|
||||||
|
key.value.support_models = JSON.stringify(key.value.support_models_array)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKey = async () => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await keyStore.updateKey(key.value);
|
||||||
|
console.log('updateKey', res)
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`Key ${key.value.name} updated`, 'success');
|
||||||
|
}
|
||||||
|
await keyStore.refreshKey(key.value.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating key:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
router.push({ name: 'ApiKey' });
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
349
frontend/src/views/dashboard/Keys.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-4">
|
||||||
|
<!-- Breadcrumb and Title -->
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
|
||||||
|
<div class="flex flex-1 items-center space-x-2">
|
||||||
|
<input
|
||||||
|
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
|
||||||
|
placeholder="Filter" value="">
|
||||||
|
|
||||||
|
<div class="dropdown">
|
||||||
|
<label tabindex="0"
|
||||||
|
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
|
||||||
|
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
||||||
|
<path fill="currentColor" fill-rule="evenodd"
|
||||||
|
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
||||||
|
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
||||||
|
<a class="px-2 mx-0 hover:rounded-none">
|
||||||
|
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
||||||
|
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
||||||
|
{{ status }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center justify-end space-x-2">
|
||||||
|
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
||||||
|
<PlusIcon class="w-3.5 h-3.5" />New
|
||||||
|
</button>
|
||||||
|
<dialog id="myModal" class="modal" ref="modalRef">
|
||||||
|
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<KeyNew @closeModal="closeModal" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end dropdown-hover">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
|
||||||
|
<Settings2Icon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
||||||
|
@click="handleBatchAction('delete')">
|
||||||
|
<TrashIcon class="w-4 h-4" />删除
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<hr class="-mx-2 my-1 border-base-content/10">
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
||||||
|
@click="handleBatchAction('disable')">
|
||||||
|
<BadgeXIcon class="w-4 h-4" />禁用
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
|
||||||
|
@click="handleBatchAction('enable')">
|
||||||
|
<BadgeCheckIcon class="w-4 h-4" />启用
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<!-- Table Header -->
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<!-- <th>Key</th> -->
|
||||||
|
<!-- <th>Endpoint</th> -->
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="key in keys" :key="key.id"
|
||||||
|
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
||||||
|
@change="toggleUserSelection(key)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs dark:text-white">
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<span class="backdrop-blur-lg glass rounded-full border-none"> <img :src="displayIcon(key.type)"
|
||||||
|
class="w-5 h-5" alt=""></span>
|
||||||
|
{{ key.type }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs dark:text-white">{{ key.name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
v-model="key.active" @change="updateStatus(key)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
|
||||||
|
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
|
||||||
|
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
||||||
|
@click="confirmDeleteKey(key)">
|
||||||
|
<TrashIcon class="w-4 h-4 dark:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||||
|
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, inject, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import Pagination from '@/components/Pagination.vue';
|
||||||
|
import KeyNew from '@/views/dashboard/KeyNew.vue';
|
||||||
|
import { useKeyStore } from '@/stores/key';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||||
|
TrashIcon, Infinity
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const keyStore = useKeyStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await keyStore.fetchKeys();
|
||||||
|
})
|
||||||
|
|
||||||
|
const keys = computed(() => keyStore.keys);
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const totalItems = computed(() => keyStore.totalKeys);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 封装公共的用户列表获取方法
|
||||||
|
const fetchKeys = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => {
|
||||||
|
currentPage.value = page || currentPage.value;
|
||||||
|
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
|
||||||
|
await keyStore.fetchKeys(size, page, active);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时加载用户数据
|
||||||
|
// onMounted(async () => {
|
||||||
|
// await fetchKeys();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 分页与页面大小变化
|
||||||
|
const changePage = async (page, size) => {
|
||||||
|
if (page == currentPage.value && size == pageSize.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentPage.value = page;
|
||||||
|
pageSize.value = size;
|
||||||
|
await fetchKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = changePage;
|
||||||
|
|
||||||
|
// 复选框选择状态
|
||||||
|
const selectAll = ref(false)
|
||||||
|
const selectedKeys = ref([])
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (keys.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys.value.forEach(key => key.selected = selectAll.value)
|
||||||
|
|
||||||
|
if (selectAll.value) {
|
||||||
|
// Select all on the current page
|
||||||
|
selectedKeys.value = users.value.map(user => user)
|
||||||
|
} else {
|
||||||
|
// Clear all selections
|
||||||
|
selectedKeys.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUserSelection = (key) => {
|
||||||
|
if (selectedKeys.value.includes(key)) {
|
||||||
|
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
|
||||||
|
} else {
|
||||||
|
selectedKeys.value.push(key);
|
||||||
|
}
|
||||||
|
selectAll.value = selectedKeys.value.length === keys.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
const statusOptions = ['Active', 'Inactive'];
|
||||||
|
const selectedStatuses = reactive([]);
|
||||||
|
|
||||||
|
const toggleStatusFilter = async (status) => {
|
||||||
|
const statusValue = status === 'Active';
|
||||||
|
const index = selectedStatuses.findIndex(item => item.status === status);
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
selectedStatuses.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedStatuses.push({ status, value: statusValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchKeys(undefined, 1, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理批量操作
|
||||||
|
const handleBatchAction = async (action) => {
|
||||||
|
if (selectedKeys.value.length === 0) {
|
||||||
|
return setToast('请选择数据', 'error');
|
||||||
|
}
|
||||||
|
if (!['enable', 'disable', 'delete'].includes(action)) {
|
||||||
|
return setToast('无效的操作 ${action}', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id));
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`Key ${action} Success`, 'success');
|
||||||
|
} else {
|
||||||
|
setToast(res.data.error || `${action} Failed`, 'error');
|
||||||
|
}
|
||||||
|
selectedKeys.value = [];
|
||||||
|
selectAll.value = false;
|
||||||
|
await fetchKeys();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`批量操作 ${action} 失败:`, error);
|
||||||
|
setToast('批量操作失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新用户状态
|
||||||
|
const updateStatus = async (key) => {
|
||||||
|
try {
|
||||||
|
const action = key.active ? 'enable' : 'disable';
|
||||||
|
const res = await keyStore.keyOption(action, [key.id]);
|
||||||
|
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||||
|
}
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态更新失败:', error);
|
||||||
|
setToast('状态更新失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewKey = (key) => {
|
||||||
|
router.push({ name: 'ApiKeyView', query: { id: key.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
const confirmDeleteKey = async (key) => {
|
||||||
|
if (confirm(`确认删除 ${key.name}?`)) {
|
||||||
|
await deleteKey(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteKey = async (key) => {
|
||||||
|
try {
|
||||||
|
const res = await keyStore.keyOption('delete', [key.id]);
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast('删除成功', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
setToast('删除失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayIcon = (apitype) => {
|
||||||
|
switch (apitype) {
|
||||||
|
case 'openai':
|
||||||
|
return '/assets/openai.svg';
|
||||||
|
case 'claude':
|
||||||
|
return '/assets/claude.svg';
|
||||||
|
case 'gemini':
|
||||||
|
return '/assets/gemini.svg'
|
||||||
|
case 'azure':
|
||||||
|
return '/assets/azure.svg';
|
||||||
|
case 'github':
|
||||||
|
return '/assets/github.svg';
|
||||||
|
default:
|
||||||
|
return '/assets/logo.svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const modalRef = ref(null);
|
||||||
|
const closeModal = async () => {
|
||||||
|
if (modalRef.value) {
|
||||||
|
modalRef.value.close();
|
||||||
|
}
|
||||||
|
await fetchKeys();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
291
frontend/src/views/dashboard/Overview.vue
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-3 md:p-6">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="card shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl mb-6" >
|
||||||
|
<div :class="['card-body text-white', getGradientClass()]">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
|
||||||
|
v-if="!user?.avatar_url">
|
||||||
|
<div
|
||||||
|
class="bg-white text-primary-content font-bold flex items-center justify-center w-full h-full">
|
||||||
|
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
|
||||||
|
v-else>
|
||||||
|
<img :src="user?.avatar_url" :alt="user?.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h1 class="text-2xl sm:text-4xl font-extrabold tracking-tight">
|
||||||
|
<span class="mr-2">👋 </span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-white/80 text-base sm:text-lg mt-1 font-medium">欢迎回到您的个人仪表盘</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 lg:gap-8">
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
||||||
|
<div class="card-body p-4 md:p-6">
|
||||||
|
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
基本信息
|
||||||
|
</h2>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">用户名</span>
|
||||||
|
<span class="badge">{{ user?.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">显示名称</span>
|
||||||
|
<span class="badge">{{ user?.name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">邮箱</span>
|
||||||
|
<span class="badge truncate max-w-full">{{ user?.email || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">角色</span>
|
||||||
|
<span class="badge" :class="user?.role > 0 ? 'badge-warning' : 'badge-ghost'">{{
|
||||||
|
getRoleName(user?.role || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
||||||
|
<div class="card-body p-4 md:p-6">
|
||||||
|
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
账户状态
|
||||||
|
</h2>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">状态</span>
|
||||||
|
<span :class="[
|
||||||
|
'badge badge-outline',
|
||||||
|
user?.active ? 'badge-success' : 'badge-error'
|
||||||
|
]">
|
||||||
|
{{ user?.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">时区</span>
|
||||||
|
<span class="badge ">{{ user?.timezone || 'UTC' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">语言</span>
|
||||||
|
<span class="badge">{{ user?.language || 'en' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base-content/70 w-20">最后活动</span>
|
||||||
|
<span class="badge">{{ getLastActive() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
||||||
|
<div class="card-body p-4 md:p-6">
|
||||||
|
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||||
|
</svg>
|
||||||
|
配额信息
|
||||||
|
</h2>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="user?.unlimited_quota">
|
||||||
|
<div class="flex flex-wrap justify-between mb-2">
|
||||||
|
<span class="font-semibold text-base-content/70">使用量</span>
|
||||||
|
<span class="badge badge-lg text-success">无限制</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex flex-wrap justify-between mb-2">
|
||||||
|
<span class="font-semibold text-base-content/70">使用量</span>
|
||||||
|
<span :class="getQuotaColor">
|
||||||
|
{{ formatQuota(user?.used_quota || 0, user?.quota||0) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress w-full"
|
||||||
|
:class="getQuotaColorClass"
|
||||||
|
:value="quotaPercentage"
|
||||||
|
max="100"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats stats-vertical shadow w-full bg-base-100">
|
||||||
|
<div class="stat p-2 md:p-4">
|
||||||
|
<div class="stat-title text-xs md:text-sm">创建时间</div>
|
||||||
|
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat p-2 md:p-4">
|
||||||
|
<div class="stat-title text-xs md:text-sm">更新时间</div>
|
||||||
|
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const user = computed(() => authStore.user);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!authStore.isLoggedIn) {
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const getTimeOfDay = () => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return '早上好';
|
||||||
|
if (hour < 18) return '下午好';
|
||||||
|
return '晚上好';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatQuota = (used, total) => {
|
||||||
|
if (total === 0) return '无限制';
|
||||||
|
|
||||||
|
// 格式化金额
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
if (amount === 0) return '$0';
|
||||||
|
return `$${amount.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentage = (used / total) * 100;
|
||||||
|
return `${formatCurrency(used)} / ${formatCurrency(total)} (${percentage.toFixed(1)}%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getRoleName = (role) => {
|
||||||
|
switch (role) {
|
||||||
|
case 20: return 'Root';
|
||||||
|
case 10: return 'Admin';
|
||||||
|
default: return 'User';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
function formatDateTime(unixTimestamp) {
|
||||||
|
// 如果时间戳不存在或为0,返回'未知'
|
||||||
|
if (!unixTimestamp) return '未知';
|
||||||
|
|
||||||
|
// 将Unix时间戳转换为毫秒
|
||||||
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
|
||||||
|
// 获取日期和时间的各个部分
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
// 返回格式化的日期时间字符串
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取背景渐变类
|
||||||
|
const getGradientClass = () => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 6) return 'bg-gradient-to-r from-[#e0f2f1] to-[#1a1a1a] bg-opacity-50 backdrop-blur-lg'; // 深夜到黎明:柔和的薄荷绿渐变到微黑
|
||||||
|
if (hour < 12) return 'bg-gradient-to-r from-[#8cc7f1] to-[#cf6f26] bg-opacity-50 backdrop-blur-lg'; // 早晨:温暖的杏仁色渐变到深灰
|
||||||
|
if (hour < 18) return 'bg-gradient-to-r from-[#e3f2fd] to-[#ad4212] bg-opacity-50 backdrop-blur-lg'; // 下午:清新的天空蓝渐变到近黑
|
||||||
|
return 'bg-gradient-to-r from-[#000000] to-[#434343] bg-opacity-50 backdrop-blur-lg'; // 夜晚:纯黑到深灰
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 计算配额百分比
|
||||||
|
const quotaPercentage = computed(() => {
|
||||||
|
if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0;
|
||||||
|
return (user.value.used_quota / user.value.quota) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取配额颜色
|
||||||
|
const getQuotaColor = computed(() => {
|
||||||
|
const percentage = quotaPercentage.value;
|
||||||
|
if (percentage < 50) return 'text-success';
|
||||||
|
if (percentage < 80) return 'text-warning';
|
||||||
|
return 'text-error';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取配额颜色类
|
||||||
|
const getQuotaColorClass = computed(() => {
|
||||||
|
const percentage = quotaPercentage.value;
|
||||||
|
if (percentage < 50) return 'progress-success';
|
||||||
|
if (percentage < 80) return 'progress-warning';
|
||||||
|
return 'progress-error';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用户最后活动时间
|
||||||
|
const getLastActive = () => {
|
||||||
|
if (!user.value || !user.value?.updated_at) return '未知';
|
||||||
|
|
||||||
|
const lastActive = new Date(user.value.updated_at * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - lastActive;
|
||||||
|
|
||||||
|
// 转换为天/小时/分钟
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
if (days > 0) return `${days}天前`;
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
if (hours > 0) return `${hours}小时前`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
if (minutes > 0) return `${minutes}分钟前`;
|
||||||
|
|
||||||
|
return '刚刚';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 添加一些过渡动画 */
|
||||||
|
.card {
|
||||||
|
transform-origin: center;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保响应式设计中文本不会溢出 */
|
||||||
|
.badge {
|
||||||
|
white-space: normal;
|
||||||
|
height: auto;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
460
frontend/src/views/dashboard/Profile.vue
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader title="User Details" />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto space-y-4">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body px-2 sm:px-8">
|
||||||
|
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||||
|
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||||
|
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||||
|
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar" v-else>
|
||||||
|
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
|
<img :src="user?.avatar_url" :alt="user?.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">
|
||||||
|
{{ user?.name }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-base-content/80">{{ user?.username }}</p>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class="badge badge-outline"
|
||||||
|
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
||||||
|
formatRole(user?.role) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right sm:text-left">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-base-content/90">Status:</span>
|
||||||
|
<span class="badge badge-outline"
|
||||||
|
:class="user.active ? 'badge-success' : 'badge-error'">
|
||||||
|
{{ user.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="font-medium text-base-content/90">Quota:</span>
|
||||||
|
<template v-if="user.unlimited_quota">
|
||||||
|
<Infinity class="inline-block w-9 text-base-content/70" />
|
||||||
|
<span class="text-sm text-base-content/70">({{ user.used_quota }} used)</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-sm text-base-content/70">{{ user.used_quota }} / {{ user.quota
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider mt-4 mb-0"></div>
|
||||||
|
|
||||||
|
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 mt-4">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
||||||
|
<Bookmark class="h-5 w-5 text-base-content/80" /> Basic Information
|
||||||
|
</h3>
|
||||||
|
<div v-if="basicinfo_error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>{{ basicinfo_error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="basicinfo_error = null">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="name" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="basicinfo.name" placeholder="Full name"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="username" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" v-model="basicinfo.username"
|
||||||
|
placeholder="Select a username" class="input input-bordered input-sm w-full"
|
||||||
|
required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="email" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Email
|
||||||
|
Address</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="email" type="email" v-model="basicinfo.email"
|
||||||
|
placeholder="email@example.com" class="input input-bordered input-sm w-full" />
|
||||||
|
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle email verification">
|
||||||
|
<BadgeCheck v-if="user.email_verified"
|
||||||
|
class="w-4 h-4 bg-green-300 rounded-full" />
|
||||||
|
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
||||||
|
Update Information
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-w-3xl mx-auto">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
|
||||||
|
<div class="flex justify-center items-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
||||||
|
<Bookmark class="h-5 w-5 text-base-content/80" /> Password
|
||||||
|
</h3>
|
||||||
|
<div v-if="password_error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>{{ password_error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="password_error = null">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="updatePassword" class="space-y-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="old_password" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Old Password</span>
|
||||||
|
</label>
|
||||||
|
<input id="old_password" type="password" v-model="passwordData.oldPassword"
|
||||||
|
placeholder="Enter current password" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="new_password" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">New Password</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="passwordData.newPassword" placeholder="Enter new password"
|
||||||
|
class="input input-bordered input-sm w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle new password visibility">
|
||||||
|
<EyeOff v-if="!isNewPasswordVisible" class="w-4 h-4" />
|
||||||
|
<Eye v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="confirm_password" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Confirm New
|
||||||
|
Password</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="passwordData.confirmPassword" placeholder="Confirm new password"
|
||||||
|
class="input input-bordered input-sm w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle confirm password visibility">
|
||||||
|
<EyeOff v-if="!isConfirmPasswordVisible" class="w-4 h-4" />
|
||||||
|
<Eye v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-outline btn-warning btn-sm">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body space-y-4 px-2 sm:px-8">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
||||||
|
<Bookmark class="h-5 w-5 text-base-content/80" /> Passkeys
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-base-content/80">Manage your passkeys for secure and passwordless login.</p>
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
||||||
|
<div class="card-body px-2 sm:px-8">
|
||||||
|
<div v-if="passkeys" class="overflow-x-auto -mx-6">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||||
|
<th class="px-2 py-3">Name</th>
|
||||||
|
<th class="px-2 py-3">Create Time</th>
|
||||||
|
<th class="px-2 py-3">SignCount</th>
|
||||||
|
<th class="px-2 py-3">Remark</th>
|
||||||
|
<th class="text-right px-2 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="passkey in passkeys" :key="passkey.id" class="hover">
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ passkey.name }}</td>
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ formatDateTime(passkey.created_at) }}</td>
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ passkey.sign_count }}</td>
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ passkey.device_type }}</td>
|
||||||
|
<td class="text-right px-2 py-3">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
@click="confirmRmovePasskey(passkey)" aria-label="Revoke token">
|
||||||
|
<TrashIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-center text-base-content/70 py-4">No passkeys found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-end pt-4">
|
||||||
|
<button class="btn btn-outline btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth">
|
||||||
|
Add Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body space-y-4 px-2 sm:px-8">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
||||||
|
<Bookmark class="h-5 w-5 text-base-content/80" /> Linked Accounts (todo)
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/80">Manage your linked social accounts for easier login.</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="border rounded-md p-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Github class="w-6 h-6 text-base-content/80" />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" :class="isGithubConnected ? 'btn-success' : 'btn-outline'">
|
||||||
|
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-md p-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Send class="w-6 h-6 fill-current text-gray-500" />
|
||||||
|
<span>Telegram</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" :class="isTelegramConnected ? 'btn-success' : 'btn-outline'">
|
||||||
|
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, inject } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Bookmark, Infinity, Github, Info } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { useWebAuthStore } from '../../stores/webauth';
|
||||||
|
import { formatDateTime} from '@/utils/format-date';
|
||||||
|
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const webAuthStore = useWebAuthStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const loading = computed(() => authStore.loading);
|
||||||
|
const user = computed(() => authStore.user);
|
||||||
|
|
||||||
|
const basicinfo_error = ref(null);
|
||||||
|
const password_error = ref(null);
|
||||||
|
|
||||||
|
const basicinfo = ref({
|
||||||
|
name: user.value?.name || '',
|
||||||
|
username: user.value?.username || '',
|
||||||
|
email: user.value?.email || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordData = ref({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNewPasswordVisible = ref(false);
|
||||||
|
const isConfirmPasswordVisible = ref(false);
|
||||||
|
const isGithubConnected = ref(false); // Replace with actual status
|
||||||
|
const isTelegramConnected = ref(false); // Replace with actual status
|
||||||
|
|
||||||
|
const supportWebAuth = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
basicinfo.value = {
|
||||||
|
name: user.value?.name || '',
|
||||||
|
username: user.value?.username || '',
|
||||||
|
email: user.value?.email || '',
|
||||||
|
};
|
||||||
|
await webAuthStore.getPasskeys();
|
||||||
|
if (window.PublicKeyCredential) {
|
||||||
|
console.log('WebAuthn is supported');
|
||||||
|
} else {
|
||||||
|
console.log('WebAuthn is not supported');
|
||||||
|
}
|
||||||
|
supportWebAuth.value = !!window.PublicKeyCredential;
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmUpdateBasicInfo = () => {
|
||||||
|
updateBasicInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBasicInfo = async () => {
|
||||||
|
if (!basicinfo.value) return;
|
||||||
|
try {
|
||||||
|
console.log('updateBasicInfo', basicinfo.value);
|
||||||
|
const res = await authStore.updateProfile(basicinfo.value);
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast('Basic information updated successfully', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
await authStore.refreshProfile(); // Refresh user data
|
||||||
|
basicinfo_error.value = null; // Clear error
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Error updating basic info:', err);
|
||||||
|
basicinfo_error.value = err || '更新失败';
|
||||||
|
setToast('Failed to update basic information', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePassword = async () => {
|
||||||
|
if (!user.value) return;
|
||||||
|
if (passwordData.value.newPassword !== passwordData.value.confirmPassword) {
|
||||||
|
setToast('新密码和确认密码不匹配', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
password: passwordData.value.oldPassword,
|
||||||
|
newpassword: passwordData.value.newPassword,
|
||||||
|
};
|
||||||
|
console.log('payload', payload)
|
||||||
|
const res = await authStore.updatePassword(payload);
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast('Password updated successfully', 'success');
|
||||||
|
}
|
||||||
|
passwordData.value.oldPassword = '';
|
||||||
|
passwordData.value.newPassword = '';
|
||||||
|
passwordData.value.confirmPassword = '';
|
||||||
|
password_error.value = null; // Clear error
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating password:', err);
|
||||||
|
password_error.value = err || '更新失败';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化角色
|
||||||
|
const formatRole = (role) => {
|
||||||
|
switch (true) {
|
||||||
|
case role > 10:
|
||||||
|
return 'Root';
|
||||||
|
case role > 0:
|
||||||
|
return 'Admin';
|
||||||
|
default:
|
||||||
|
return 'User';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const toggleEmailVerify = () => {
|
||||||
|
if (user.value && !user.value.email_verified) {
|
||||||
|
// todo: Implement logic to send verification email
|
||||||
|
setToast('todo,Sending verification email...', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNewPasswordVisibility = () => {
|
||||||
|
isNewPasswordVisible.value = !isNewPasswordVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleConfirmPasswordVisibility = () => {
|
||||||
|
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGithubConnection = () => {
|
||||||
|
isGithubConnected.value = !isGithubConnected.value;
|
||||||
|
setToast(`GitHub ${isGithubConnected.value ? 'connected' : 'disconnected'}`, 'info');
|
||||||
|
// Implement actual connection/disconnection logic here
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTelegramConnection = () => {
|
||||||
|
isTelegramConnected.value = !isTelegramConnected.value;
|
||||||
|
setToast(`Telegram ${isTelegramConnected.value ? 'connected' : 'disconnected'}`, 'info');
|
||||||
|
// Implement actual connection/disconnection logic here
|
||||||
|
};
|
||||||
|
|
||||||
|
const newpasskey = async () => {
|
||||||
|
try {
|
||||||
|
let res = await webAuthStore.addPasskey();
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
await getPasskeys();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('err', err);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passkeys = computed(() => webAuthStore.passkeys);
|
||||||
|
|
||||||
|
const getPasskeys = async () => {
|
||||||
|
try {
|
||||||
|
await webAuthStore.getPasskeys();
|
||||||
|
} catch (err) {
|
||||||
|
console.log('err', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRmovePasskey = async (passkey) => {
|
||||||
|
if(confirm(`确认删除 ${passkey.name}?`)) {
|
||||||
|
await removePasskey(passkey.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const removePasskey = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await webAuthStore.deletePasskey(id);
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast('Passkey removed successfully', 'success');
|
||||||
|
}
|
||||||
|
await getPasskeys();
|
||||||
|
} catch (err) {
|
||||||
|
console.log('err', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
261
frontend/src/views/dashboard/Settings.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader title="User Details" />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="flex flex-col md:flex-row items-center gap-6">
|
||||||
|
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||||
|
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||||
|
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar" v-else>
|
||||||
|
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
|
<img :src="user?.avatar_url" :alt="user?.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow text-center md:text-left">
|
||||||
|
<h2 class="text-2xl font-semibold text-base-content">
|
||||||
|
{{ user?.name || user?.username }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
|
||||||
|
<span class="badge border-none px-0">
|
||||||
|
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
|
||||||
|
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline"
|
||||||
|
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
||||||
|
formatRole(user?.role) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4 md:mt-0">
|
||||||
|
<input type="checkbox" class="toggle toggle-md"
|
||||||
|
:class="user.active ? 'toggle-success' : 'toggle-error'" v-model="user.active"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider mt-1 mb-0"></div>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateUser" class="space-y-4">
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="name" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="user.name" placeholder="Full name"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="username" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" v-model="user.username"
|
||||||
|
placeholder="Select a username" class="input input-bordered input-sm w-full"
|
||||||
|
required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="email" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Email
|
||||||
|
Address</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="email" type="email" v-model="user.email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
|
<button type="button" @click="toggleEmailVerify"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle password visibility">
|
||||||
|
<BadgeCheck v-if="user.email_verified"
|
||||||
|
class="w-4 h-4 bg-green-300 rounded-full" />
|
||||||
|
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="password" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">
|
||||||
|
Password <span class="text-xs text-base-content/60">(Leave blank to keep
|
||||||
|
unchanged)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="user.password" placeholder="Enter new password"
|
||||||
|
class="input input-bordered input-sm w-full pr-10" />
|
||||||
|
<button type="button" @click="togglePasswordVisibility"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle password visibility">
|
||||||
|
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
|
||||||
|
<Eye v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
|
||||||
|
<input type="checkbox" class="min-h-0 py-2" checked />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-2">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="role" class="label pb-1">
|
||||||
|
<span
|
||||||
|
class="label-text text-sm font-medium text-base-content/80">Role</span>
|
||||||
|
</label>
|
||||||
|
<select id="role" v-model="user.role"
|
||||||
|
class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="0">User</option>
|
||||||
|
<option :value="10">Admin</option>
|
||||||
|
<option v-if="user.role > 10" :value="20">Root</option>
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="language" class="label pb-1">
|
||||||
|
<span
|
||||||
|
class="label-text text-sm font-medium text-base-content/80">Language</span>
|
||||||
|
</label>
|
||||||
|
<select id="language" v-model="user.language"
|
||||||
|
class="select select-bordered select-sm w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control w-full">
|
||||||
|
<label for="quota" class="label pb-1">
|
||||||
|
<span
|
||||||
|
class="label-text text-sm font-medium text-base-content/80">Quota</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input id="quota" type="number" v-model="user.quota"
|
||||||
|
placeholder="Enter quota amount"
|
||||||
|
class="input input-bordered input-sm flex-grow"
|
||||||
|
:disabled="user.unlimited_quota" />
|
||||||
|
<label class="label cursor-pointer space-x-2 p-0">
|
||||||
|
<input type="checkbox" v-model="user.unlimited_quota"
|
||||||
|
class="checkbox checkbox-sm" />
|
||||||
|
<span class="label-text text-sm text-base-content/90">Unlimited</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-w-3xl mx-auto">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-center items-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, inject } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const loading = computed(() => authStore.loading);
|
||||||
|
const user = computed(() => authStore.user);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.refreshProfile()
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUser = async () => {
|
||||||
|
if (!user.value) return;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: user.value.name,
|
||||||
|
username: user.value.username,
|
||||||
|
email: user.value.email,
|
||||||
|
language: user.value.language,
|
||||||
|
};
|
||||||
|
if (user.value.password) {
|
||||||
|
payload.password = user.value.password;
|
||||||
|
}
|
||||||
|
const res = await userStore.editUser(userId.value, payload);
|
||||||
|
console.log('updateUser', res)
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`User ${userId.value} updated`, 'success');
|
||||||
|
}
|
||||||
|
await userStore.refreshUser(userId.value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating user:', err.response?.data?.data?.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//显示密码
|
||||||
|
const isPasswordVisible = ref(false);
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化角色
|
||||||
|
const formatRole = (role) => {
|
||||||
|
switch (true) {
|
||||||
|
case role > 10:
|
||||||
|
return 'Root';
|
||||||
|
case role > 0:
|
||||||
|
return 'Admin';
|
||||||
|
default:
|
||||||
|
return 'U';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const toggleEmailVerify = () => {
|
||||||
|
if (user.value && !user.value.email_verified) {
|
||||||
|
// todo
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
252
frontend/src/views/dashboard/TokenNew.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="mb-6 text-center md:text-left">
|
||||||
|
<h1 class="text-2xl font-semibold text-base-content">
|
||||||
|
Create New Token
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
Enter the token's details below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>Error! {{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="error = null">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300/40 shadow-sm">
|
||||||
|
<form @submit.prevent="createToken" class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="username" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" v-model="newToken.name" placeholder="New Token Name"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" value="false" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-0">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="password" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Key
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="token" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
<button type="button" @click="toggleTokenVisibility"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
|
||||||
|
id="token-visibility-toggle">
|
||||||
|
|
||||||
|
<template v-if="!isTokenVisible">
|
||||||
|
<EyeOff class="w-5 h-5" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Eye class="w-5 h-5" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label label-text text-xs">Expired at</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="date" v-model="newToken.format_expired_at" class="input input-bordered input-sm w-full"
|
||||||
|
placeholder="年/月/日" :disabled="newToken.never_expired" />
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
||||||
|
<input type="checkbox" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm text-base-content/90">Never</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="quota" class="label">
|
||||||
|
<span class="label-text">Quota</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input id="quota" type="number" v-model="newToken.quota" placeholder="Enter quota amount"
|
||||||
|
class="input input-sm input-bordered w-1/2 flex-grow" :disabled="newToken.unlimited_quota" />
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
||||||
|
<input type="checkbox" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm text-base-content/90">Unlimited</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3 h-9">
|
||||||
|
<input type="checkbox" v-model="newToken.active"
|
||||||
|
:class="`toggle toggle-sm ${newToken.active ? 'toggle-success' : 'toggle-error'}`" />
|
||||||
|
<span class="text-sm text-base-content/90">
|
||||||
|
{{ newToken.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4 gap-5">
|
||||||
|
<button type="button" class="btn btn-outline btn-error btn-sm h-9 px-4 text-sm font-medium" @click="cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, inject, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import { Eye, EyeOff } from 'lucide-vue-next'
|
||||||
|
import { dateToUnix } from '@/utils/format-date.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { setToast } = inject('toast')
|
||||||
|
const error = ref(null)
|
||||||
|
const user = computed(() => authStore.user);
|
||||||
|
|
||||||
|
const showAdvancedOptions = ref(false)
|
||||||
|
|
||||||
|
|
||||||
|
const newToken = ref({
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
user_id: user.user_id,
|
||||||
|
active: true,
|
||||||
|
quota: 0,
|
||||||
|
unlimited_quota: true,
|
||||||
|
expired_at: 0,
|
||||||
|
format_expired_at: '',
|
||||||
|
never_expired: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetnewToken = () => {
|
||||||
|
newToken.value = {
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
user_id: '',
|
||||||
|
active: true,
|
||||||
|
quota: 0,
|
||||||
|
unlimited_quota: true,
|
||||||
|
expired_at: 0,
|
||||||
|
format_expired_at: '',
|
||||||
|
never_expired: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => newToken.value.never_expired,
|
||||||
|
(newNeverExpiredValue) => {
|
||||||
|
if (newNeverExpiredValue) {
|
||||||
|
newToken.value.expired_at = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => newToken.value.format_expired_at,
|
||||||
|
(format_expired_at) => {
|
||||||
|
if (!newToken.value.never_expired && format_expired_at) {
|
||||||
|
newToken.value.expired_at = dateToUnix(format_expired_at);
|
||||||
|
} else {
|
||||||
|
newToken.value.expired_at = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return newToken.value.name
|
||||||
|
})
|
||||||
|
|
||||||
|
const createToken = async () => {
|
||||||
|
if (!isFormValid.value) {
|
||||||
|
setToast('Please fill in all required fields Name.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authStore.createToken(newToken.value)
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
error.value = null;
|
||||||
|
resetnewToken();
|
||||||
|
setToast(`Token ${newToken.value.name} created`, 'success')
|
||||||
|
emit('closeModal', true)
|
||||||
|
} else {
|
||||||
|
console.log(res)
|
||||||
|
error.value = res.data?.error || 'Failed to create token'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to create token'
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
resetnewToken()
|
||||||
|
emit('closeModal', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteToken = async (id) => {
|
||||||
|
console.log(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示密码
|
||||||
|
const isTokenVisible = ref(false);
|
||||||
|
|
||||||
|
function toggleTokenVisibility() {
|
||||||
|
isTokenVisible.value = !isTokenVisible.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['closeModal'])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Minimal custom styles if absolutely necessary */
|
||||||
|
.collapse .collapse-title {
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
212
frontend/src/views/dashboard/Tokens.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-4">
|
||||||
|
<!-- Breadcrumb and Title -->
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4 justify-end items-center">
|
||||||
|
|
||||||
|
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
||||||
|
<PlusIcon class="w-3.5 h-3.5" />New
|
||||||
|
</button>
|
||||||
|
<dialog id="myModal" class="modal" ref="modalRef">
|
||||||
|
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<TokenNew @closeModal="closeModal" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||||
|
<th class="px-2 py-3">Token</th>
|
||||||
|
<th class="px-2 py-3">Status</th>
|
||||||
|
<!-- <th class="px-2 py-3">Key</th> -->
|
||||||
|
<th class="px-2 py-3">Expired</th>
|
||||||
|
<th class="px-2 py-3">Quota</th>
|
||||||
|
<th class="px-2 py-3">Used</th>
|
||||||
|
<th class="text-right px-2 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="toggle toggle-xs"
|
||||||
|
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
|
||||||
|
@change="updateStatus(token)" />
|
||||||
|
</td>
|
||||||
|
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
|
||||||
|
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
|
||||||
|
<td class="px-2 py-3">
|
||||||
|
<template v-if="token.unlimited_quota">
|
||||||
|
<Infinity />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ token.quota }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||||
|
<td class="text-right px-1 py-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
|
||||||
|
<EyeIcon class="w-5 h-5 dark:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:tooltip" data-tip="clean used">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
|
||||||
|
aria-label="Revoke token">
|
||||||
|
<Eraser class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="token.name !== 'default'"
|
||||||
|
class="btn btn-ghost btn-xs btn-square text-error items-center" @click="confirmRevokeToken(token)"
|
||||||
|
aria-label="Revoke token">
|
||||||
|
<TrashIcon class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<dialog id="myToken" class="modal" ref="tokenRef">
|
||||||
|
<div class="modal-box px-0 sm:px-8">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<QRCodeCard :value="qrCodeValue" :size="120" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
||||||
|
</div>
|
||||||
|
<!-- </div> -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, inject, computed, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import QRCodeCard from '@/components/QRCodeCard.vue';
|
||||||
|
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import {
|
||||||
|
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { unixToDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const user = computed(() => authStore.user);
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => authStore.user, async (newUser) => {
|
||||||
|
if (newUser.expired_at > 0) {
|
||||||
|
newUser.format_expired_at = unixToDate(newUser.expired_at);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateStatus = async (token) => {
|
||||||
|
console.log(token);
|
||||||
|
try {
|
||||||
|
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`Token ${token.name} updated`, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
token.active = !token.active
|
||||||
|
console.log(error.response.data.error);
|
||||||
|
setToast(error.response.data.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRevokeToken = async (token) => {
|
||||||
|
if (confirm(`确认删除 ${token.name}?`)) {
|
||||||
|
await revokeToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeToken = async (token) => {
|
||||||
|
try {
|
||||||
|
const res = await authStore.deleteToken(token.id);
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`Token ${token.name} revoked`, 'success');
|
||||||
|
}
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setToast(error.response.data.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUsedToken = async (token) => {
|
||||||
|
|
||||||
|
if (token.used_quota == 0 || token.used_quota == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await authStore.resetToken(token.id);
|
||||||
|
console.log('cleanUsedToken', res);
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`Token ${token.name} used quota reset`, 'success');
|
||||||
|
}
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setToast(error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTokenModel = ref(false);
|
||||||
|
const tokenRef = ref(null);
|
||||||
|
const viewToken = (token) => {
|
||||||
|
const dialog = tokenRef.value;
|
||||||
|
if (dialog) {
|
||||||
|
if (!dialog.hasAttribute('open')) {
|
||||||
|
qrCodeValue.value = token.key;
|
||||||
|
dialog.showModal();
|
||||||
|
} else {
|
||||||
|
if (dialog.hasAttribute('open')) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showTokenModel.value = !showTokenModel.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrCodeValue = ref('');
|
||||||
|
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const modalRef = ref(null);
|
||||||
|
const closeModal = async () => {
|
||||||
|
if (modalRef.value) {
|
||||||
|
modalRef.value.close();
|
||||||
|
}
|
||||||
|
await authStore.refreshProfile();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
324
frontend/src/views/dashboard/User.vue
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-4">
|
||||||
|
<!-- Breadcrumb and Title -->
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<!-- Search and Controls -->
|
||||||
|
<!-- <div v-if="userStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<div class="flex flex-1 items-center space-x-2">
|
||||||
|
<input
|
||||||
|
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
|
||||||
|
placeholder="Filter" value="">
|
||||||
|
|
||||||
|
<div class="dropdown">
|
||||||
|
<label tabindex="0"
|
||||||
|
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
|
||||||
|
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
||||||
|
<path fill="currentColor" fill-rule="evenodd"
|
||||||
|
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
||||||
|
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
||||||
|
<a class="px-2 mx-0 hover:rounded-none">
|
||||||
|
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
||||||
|
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
||||||
|
{{ status }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center justify-end space-x-1">
|
||||||
|
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
||||||
|
<PlusIcon class="w-3.5 h-3.5" />New
|
||||||
|
</button>
|
||||||
|
<dialog id="myModal" class="modal" ref="modalRef">
|
||||||
|
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<UserNew @closeModal="closeModal" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end dropdown-hover">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
|
||||||
|
<Settings2Icon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
||||||
|
@click="handleBatchAction('delete')">
|
||||||
|
<TrashIcon class="w-4 h-4" />删除
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<hr class="-mx-2 my-1 border-base-content/10">
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
||||||
|
@click="handleBatchAction('disable')">
|
||||||
|
<BadgeXIcon class="w-4 h-4" />禁用
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
|
||||||
|
@click="handleBatchAction('enable')">
|
||||||
|
<BadgeCheckIcon class="w-4 h-4" />启用
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<!-- Table Header -->
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
||||||
|
</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Quota</th>
|
||||||
|
<th>Used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.id"
|
||||||
|
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
|
||||||
|
@change="toggleUserSelection(user)" />
|
||||||
|
</td>
|
||||||
|
<td class="text-xs dark:text-white">{{ user.id }}</td>
|
||||||
|
<td class="text-xs dark:text-white">{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
v-model="user.active" @change="updateStatus(user)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs dark:text-white">
|
||||||
|
<template v-if="user.unlimited_quota">
|
||||||
|
<Infinity />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ user.quota }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs dark:text-white">{{ user.used_quota }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
|
||||||
|
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
||||||
|
@click="confirmDeleteUser(user)">
|
||||||
|
<TrashIcon class="w-4 h-4 dark:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||||
|
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, inject, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import Pagination from '@/components/Pagination.vue';
|
||||||
|
import UserNew from '@/views/dashboard/UserNew.vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import {
|
||||||
|
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||||
|
TrashIcon, Infinity
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const users = computed(() => userStore.users);
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const totalItems = computed(() => userStore.totalUsers);
|
||||||
|
|
||||||
|
// 封装公共的用户列表获取方法
|
||||||
|
const listUsers = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => {
|
||||||
|
currentPage.value = page || currentPage.value;
|
||||||
|
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
|
||||||
|
await userStore.listUser(size, page, active);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时加载用户数据
|
||||||
|
onMounted(() => {
|
||||||
|
listUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分页与页面大小变化
|
||||||
|
const changePage = async (page, size) => {
|
||||||
|
if (page == currentPage.value && size == pageSize.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentPage.value = page;
|
||||||
|
pageSize.value = size;
|
||||||
|
await listUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = changePage;
|
||||||
|
|
||||||
|
// 复选框选择状态
|
||||||
|
const selectAll = ref(false)
|
||||||
|
const selectedUsers = ref([])
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
users.value.forEach(key => key.selected = selectAll.value)
|
||||||
|
|
||||||
|
if (selectAll.value) {
|
||||||
|
// Select all users on the current page
|
||||||
|
selectedUsers.value = users.value.map(user => user)
|
||||||
|
} else {
|
||||||
|
// Clear all selections
|
||||||
|
selectedUsers.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUserSelection = (user) => {
|
||||||
|
if (selectedUsers.value.includes(user)) {
|
||||||
|
selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user);
|
||||||
|
} else {
|
||||||
|
selectedUsers.value.push(user);
|
||||||
|
}
|
||||||
|
selectAll.value = selectedUsers.value.length === users.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
const statusOptions = ['Active', 'Inactive'];
|
||||||
|
const selectedStatuses = reactive([]);
|
||||||
|
|
||||||
|
const toggleStatusFilter = async (status) => {
|
||||||
|
const statusValue = status === 'Active';
|
||||||
|
const index = selectedStatuses.findIndex(item => item.status === status);
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
selectedStatuses.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedStatuses.push({ status, value: statusValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
await listUsers(undefined, 1, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理批量操作
|
||||||
|
const handleBatchAction = async (action) => {
|
||||||
|
if (selectedUsers.value.length === 0) {
|
||||||
|
return setToast('请选择用户', 'error');
|
||||||
|
}
|
||||||
|
if (!['enable', 'disable', 'delete'].includes(action)) {
|
||||||
|
return setToast('无效的操作 ${action}', 'error');
|
||||||
|
}
|
||||||
|
if (selectedUsers.value.length === 0) {
|
||||||
|
return setToast('请选择用户', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await userStore.userOption(action, selectedUsers.value.map(user => user.id));
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`Users ${action} Success`, 'success');
|
||||||
|
} else {
|
||||||
|
setToast(res.error || `${action} Failed`, 'error');
|
||||||
|
}
|
||||||
|
selectedUsers.value = [];
|
||||||
|
selectAll.value = false;
|
||||||
|
await listUsers();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`批量操作 ${action} 失败:`, error);
|
||||||
|
setToast('批量操作失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新用户状态
|
||||||
|
const updateStatus = async (user) => {
|
||||||
|
try {
|
||||||
|
const action = user.active ? 'enable' : 'disable';
|
||||||
|
const res = await userStore.userOption(action, [user.id]);
|
||||||
|
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`User ${user.name} has been ${action}`, 'success');
|
||||||
|
} else {
|
||||||
|
setToast(res.error || `用户 ${user.id} ${action} 失败`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
await listUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态更新失败:', error);
|
||||||
|
setToast('状态更新失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewUser = (user) => {
|
||||||
|
router.push({ name: 'UserView', query: { id: user.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteUser = (user) => {
|
||||||
|
if (confirm(`确认删除 ${user.username}?`)) {
|
||||||
|
deleteUser(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 删除用户
|
||||||
|
const deleteUser = async (user) => {
|
||||||
|
try {
|
||||||
|
const res = await userStore.userOption('delete', [user.id]);
|
||||||
|
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast('用户删除成功', 'success');
|
||||||
|
} else {
|
||||||
|
setToast(res.error || '删除失败', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
await listUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
setToast('删除失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const modalRef = ref(null);
|
||||||
|
const closeModal = async () => {
|
||||||
|
if (modalRef.value) {
|
||||||
|
modalRef.value.close();
|
||||||
|
}
|
||||||
|
await listUsers();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
255
frontend/src/views/dashboard/UserNew.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="mb-6 text-center md:text-left">
|
||||||
|
<h1 class="text-2xl font-semibold text-base-content">
|
||||||
|
Create New User
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
Enter the user's details below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>Error! {{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" @click="error = null">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300/40 shadow-sm">
|
||||||
|
<form @submit.prevent="createUser" class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="username" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" v-model="newUser.username" placeholder="Select a username"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="password" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
Password <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="newUser.password"
|
||||||
|
class="input input-sm input-bordered w-full" required />
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
|
||||||
|
id="password-visibility-toggle">
|
||||||
|
|
||||||
|
<template v-if="!isPasswordVisible">
|
||||||
|
<EyeOff class="w-5 h-5" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Eye class="w-5 h-5" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text">Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="newUser.name" placeholder="Full name"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="email" class="label">
|
||||||
|
<span class="label-text">Email Address</span>
|
||||||
|
</label>
|
||||||
|
<input id="email" type="email" v-model="newUser.email" placeholder="email@example.com"
|
||||||
|
class="input input-sm input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-0">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="role" class="label">
|
||||||
|
<span class="label-text">Role</span>
|
||||||
|
</label>
|
||||||
|
<select id="role" v-model="newUser.role" class="select select-sm select-bordered w-full">
|
||||||
|
<option :value="0">User</option>
|
||||||
|
<option :value="10">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="language" class="label">
|
||||||
|
<span class="label-text">Language</span>
|
||||||
|
</label>
|
||||||
|
<select id="language" v-model="newUser.language" class="select select-sm select-bordered w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control">
|
||||||
|
<label for="quota" class="label">
|
||||||
|
<span class="label-text">Quota</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input id="quota" type="number" v-model="newUser.quota" placeholder="Enter quota amount"
|
||||||
|
class="input input-sm input-bordered flex-grow" :disabled="newUser.unlimited_quota" />
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
||||||
|
<input type="checkbox" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm text-base-content/90">Unlimited</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3 h-9">
|
||||||
|
<input type="checkbox" v-model="newUser.active"
|
||||||
|
:class="`toggle toggle-sm ${newUser.active ? 'toggle-success' : 'toggle-error'}`" />
|
||||||
|
<span class="text-sm text-base-content/90">
|
||||||
|
{{ newUser.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, inject } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import { Eye, EyeOff } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { setToast } = inject('toast')
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// Control advanced options visibility
|
||||||
|
const showAdvancedOptions = ref(false)
|
||||||
|
|
||||||
|
// Initialize user object
|
||||||
|
const newUser = ref({
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 0, // Default to Regular User
|
||||||
|
active: true, // Default to Active
|
||||||
|
quota: 0, // Default quota value (relevant if not unlimited)
|
||||||
|
unlimited_quota: true, // Default to unlimited
|
||||||
|
language: 'en', // Default language
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetNewUser = () => {
|
||||||
|
newUser.value = {
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 0, // Default to Regular User
|
||||||
|
active: true, // Default to Active
|
||||||
|
quota: 0, // Default quota value (relevant if not unlimited)
|
||||||
|
unlimitedQuota: true, // Default to unlimited
|
||||||
|
language: 'en', // Default language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return newUser.value.username &&
|
||||||
|
newUser.value.password // Password is required for creation
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create user method
|
||||||
|
const createUser = async () => {
|
||||||
|
if (!isFormValid.value) {
|
||||||
|
setToast('Please fill in all required fields (Username, Password).', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await userStore.createUser({
|
||||||
|
username: newUser.value.username,
|
||||||
|
password: newUser.value.password,
|
||||||
|
email: newUser.value.email,
|
||||||
|
name: newUser.value.name || newUser.value.username, // Use username if name is empty
|
||||||
|
role: newUser.value.role,
|
||||||
|
active: newUser.value.active,
|
||||||
|
quota: newUser.value.quota,
|
||||||
|
unlimited_quota: newUser.value.unlimited_quota,
|
||||||
|
language: newUser.value.language
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
error.value = null;
|
||||||
|
resetNewUser();
|
||||||
|
setToast('User created successfully', 'success')
|
||||||
|
// Optionally navigate or reset form
|
||||||
|
emit('closeModal', true)
|
||||||
|
} else {
|
||||||
|
setToast(res.error || res.data?.message || 'Failed to create user', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to create user'
|
||||||
|
// setToast(error.response?.data?.error || 'Failed to create user', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示密码
|
||||||
|
const isPasswordVisible = ref(false);
|
||||||
|
|
||||||
|
function togglePasswordVisibility() {
|
||||||
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['closeModal'])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Minimal custom styles if absolutely necessary */
|
||||||
|
.collapse .collapse-title {
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
341
frontend/src/views/dashboard/UserView.vue
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||||
|
<BreadcrumbHeader title="User Details" />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||||
|
<div class="card-body space-y-5 p-3 sm:p-8">
|
||||||
|
<div class="flex flex-col md:flex-row items-center gap-6">
|
||||||
|
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||||
|
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||||
|
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar" v-else>
|
||||||
|
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
|
<img :src="user?.avatar_url" :alt="user?.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow text-center md:text-left">
|
||||||
|
<h2 class="text-2xl font-semibold text-base-content">
|
||||||
|
{{ user?.name || user?.username }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
|
||||||
|
<span class="badge border-none px-0">
|
||||||
|
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
|
||||||
|
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline" :class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
||||||
|
formatRole(user?.role) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4 md:mt-0">
|
||||||
|
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
v-model="user.active" @change="updateStatus(user)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider mt-1 mb-0"></div>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateUser" class="space-y-4">
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-base font-medium text-base-content mb-3">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="name" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" v-model="user.name" placeholder="Full name"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="username" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" v-model="user.username" placeholder="Select a username"
|
||||||
|
class="input input-bordered input-sm w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="email" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Email Address</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="email" type="email" v-model="user.email" placeholder="email@example.com"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
|
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle password visibility">
|
||||||
|
<BadgeCheck v-if="user.email_verified" class="w-4 h-4 bg-green-300 rounded-full" />
|
||||||
|
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="password" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">
|
||||||
|
Password <span class="text-xs text-base-content/60">(Leave blank to keep unchanged)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="user.password"
|
||||||
|
placeholder="Enter new password" class="input input-bordered input-sm w-full pr-10" />
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
||||||
|
aria-label="Toggle password visibility">
|
||||||
|
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
|
||||||
|
<Eye v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
|
||||||
|
<input type="checkbox" class="min-h-0 py-2" checked />
|
||||||
|
<div class="collapse-title text-base font-medium min-h-0 py-2">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="role" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Role</span>
|
||||||
|
</label>
|
||||||
|
<select id="role" v-model="user.role" class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="0">User</option>
|
||||||
|
<option :value="10">Admin</option>
|
||||||
|
<option v-if="user.role > 10" :value="20">Root</option>
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="language" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Language</span>
|
||||||
|
</label>
|
||||||
|
<select id="language" v-model="user.language" class="select select-bordered select-sm w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 form-control w-full">
|
||||||
|
<label for="quota" class="label pb-1">
|
||||||
|
<span class="label-text text-sm font-medium text-base-content/80">Quota</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input id="quota" type="number" v-model="user.quota" placeholder="Enter quota amount"
|
||||||
|
class="input input-bordered input-sm w-1/2" :disabled="user.unlimited_quota" />
|
||||||
|
<label class="label cursor-pointer space-x-2 p-0">
|
||||||
|
<input type="checkbox" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
<span class="label-text text-sm text-base-content/90">Unlimited</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-w-3xl mx-auto">
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-center items-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token 显示 -->
|
||||||
|
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Tokens</h3>
|
||||||
|
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||||
|
<th class="px-2 py-3">Token Name</th>
|
||||||
|
<th class="px-2 py-3">Key</th>
|
||||||
|
<th class="px-2 py-3">Expired At</th>
|
||||||
|
<th class="px-2 py-3">Quota</th>
|
||||||
|
<th class="px-2 py-3">Used</th>
|
||||||
|
<th class="text-right px-2 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||||
|
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
|
||||||
|
<td class="px-2 py-3">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
|
||||||
|
<td class="px-2 py-3">
|
||||||
|
<template v-if="token.unlimited_quota">
|
||||||
|
<Infinity />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ token.quota }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||||
|
<td class="text-right px-2 py-3">
|
||||||
|
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
@click="revokeToken(token.id)" aria-label="Revoke token">
|
||||||
|
<TrashIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, inject } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
|
||||||
|
import { useUserStore } from '../../stores/user';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { setToast } = inject('toast');
|
||||||
|
|
||||||
|
const userId = computed(() => route.query.id);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (userId.value) {
|
||||||
|
await userStore.getUser(userId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = computed(() => userStore.user);
|
||||||
|
const loading = computed(() => userStore.loading); // Access loading state
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
const updateStatus = async (user) => {
|
||||||
|
try {
|
||||||
|
const action = user.active ? 'enable' : 'disable';
|
||||||
|
const res = await userStore.userOption(action, [user.id]);
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`User ${user.id} ${action} Success`, 'success');
|
||||||
|
} else {
|
||||||
|
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
|
||||||
|
}
|
||||||
|
await userStore.refreshUser(user.id);
|
||||||
|
} catch (error) {
|
||||||
|
user.active = !user.active;
|
||||||
|
console.error('状态更新失败:', error);
|
||||||
|
// setToast(error.response.data?.error || '状态更新失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async () => {
|
||||||
|
if (!user.value) return;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: user.value.name,
|
||||||
|
username: user.value.username,
|
||||||
|
email: user.value.email,
|
||||||
|
role: user.value.role,
|
||||||
|
language: user.value.language,
|
||||||
|
quota: user.value.quota,
|
||||||
|
unlimited_quota: user.value.unlimited_quota,
|
||||||
|
};
|
||||||
|
// Only include password if it's not empty
|
||||||
|
if (user.value.password) {
|
||||||
|
payload.password = user.value.password;
|
||||||
|
}
|
||||||
|
const res = await userStore.editUser(userId.value, payload);
|
||||||
|
console.log('updateUser', res)
|
||||||
|
if (res.data?.code == 200) {
|
||||||
|
setToast(`User ${userId.value} updated`, 'success');
|
||||||
|
}
|
||||||
|
await userStore.refreshUser(userId.value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating user:', err.response?.data?.data?.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//显示密码
|
||||||
|
const isPasswordVisible = ref(false);
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化角色
|
||||||
|
const formatRole = (role) => {
|
||||||
|
switch (true) {
|
||||||
|
case role > 10:
|
||||||
|
return 'Root';
|
||||||
|
case role > 0:
|
||||||
|
return 'Admin';
|
||||||
|
default:
|
||||||
|
return 'U';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// const updateStatus = async (updatedUser) => {
|
||||||
|
// try {
|
||||||
|
// const response = await userStore.editUser(updatedUser.id, { active: updatedUser.active });
|
||||||
|
// if (response.data?.data?.code == 200) {
|
||||||
|
// setToast('User ${updatedUser.id} status updated', 'success');
|
||||||
|
// }
|
||||||
|
// } catch (err) {
|
||||||
|
// updatedUser.active = !updatedUser.active;
|
||||||
|
// console.log('Error updating user status:', err.response?.data?.data?.error);
|
||||||
|
// setToast(err.response?.data?.data?.error, 'error')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(dateString * 1000)); // Multiply by 1000 for JavaScript Date
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error formatting date:", e);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除token
|
||||||
|
const revokeToken = (tokenId) => {
|
||||||
|
console.log('Revoking token:', tokenId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEmailVerify = () => {
|
||||||
|
if (user.value && !user.value.email_verified) {
|
||||||
|
// todo
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
16
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
import daisyui from 'daisyui';
|
||||||
|
export default{
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [daisyui],
|
||||||
|
daisyui: {
|
||||||
|
themes: ["light", "dark","cupcake","emerald","pastel"], // 可以根据需要添加或修改主题
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
40
frontend/vite.config.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import basicSsl from '@vitejs/plugin-basic-ssl'; // 推荐使用这个插件简化自签名证书管理
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(),basicSsl()],
|
||||||
|
server: {
|
||||||
|
https: true, // 启用 HTTPS
|
||||||
|
host: 'localhost', // 确保 host 是 localhost
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
// sourcemap: true,
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('src/components')) {
|
||||||
|
return 'components';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.includes('src/views/dashboard')) {
|
||||||
|
return 'views-dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.includes('src/stores')) {
|
||||||
|
return 'stores';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
108
go.mod
@@ -5,93 +5,117 @@ go 1.23.2
|
|||||||
require (
|
require (
|
||||||
cloud.google.com/go/vertexai v0.13.1
|
cloud.google.com/go/vertexai v0.13.1
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d
|
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d
|
||||||
|
github.com/bluele/gcache v0.0.2
|
||||||
github.com/coder/websocket v1.8.12
|
github.com/coder/websocket v1.8.12
|
||||||
github.com/duke-git/lancet/v2 v2.3.3
|
github.com/duke-git/lancet/v2 v2.3.3
|
||||||
github.com/faiface/beep v1.1.0
|
github.com/faiface/beep v1.1.0
|
||||||
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||||
|
github.com/go-webauthn/webauthn v0.12.3
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/google/generative-ai-go v0.18.0
|
github.com/google/generative-ai-go v0.18.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/google/wire v0.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/liushuangls/go-anthropic/v2 v2.15.0
|
||||||
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/pkoukk/tiktoken-go v0.1.7
|
github.com/pkoukk/tiktoken-go v0.1.7
|
||||||
github.com/sashabaranov/go-openai v1.32.2
|
github.com/sashabaranov/go-openai v1.32.2
|
||||||
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/sync v0.8.0
|
golang.org/x/crypto v0.37.0
|
||||||
google.golang.org/api v0.201.0
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
|
||||||
|
golang.org/x/sync v0.13.0
|
||||||
|
golang.org/x/time v0.10.0
|
||||||
|
google.golang.org/api v0.224.0
|
||||||
|
google.golang.org/genai v1.0.0
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.116.0 // indirect
|
cloud.google.com/go v0.120.0 // indirect
|
||||||
cloud.google.com/go/ai v0.8.2 // indirect
|
cloud.google.com/go/ai v0.8.2 // indirect
|
||||||
cloud.google.com/go/aiplatform v1.68.0 // indirect
|
cloud.google.com/go/aiplatform v1.74.0 // indirect
|
||||||
cloud.google.com/go/auth v0.9.8 // indirect
|
cloud.google.com/go/auth v0.15.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 // indirect
|
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||||
cloud.google.com/go/iam v1.2.1 // indirect
|
cloud.google.com/go/iam v1.4.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.6.1 // indirect
|
cloud.google.com/go/longrunning v0.6.4 // indirect
|
||||||
github.com/bytedance/sonic v1.12.3 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||||
github.com/gin-contrib/cors v1.7.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.22.1 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/go-webauthn/x v0.1.20 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.3 // indirect
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
||||||
github.com/google/s2a-go v0.1.8 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
|
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.31.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.31.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.31.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
golang.org/x/arch v0.11.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
golang.org/x/crypto v0.28.0 // indirect
|
golang.org/x/arch v0.16.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/net v0.30.0 // indirect
|
golang.org/x/oauth2 v0.28.0 // indirect
|
||||||
golang.org/x/oauth2 v0.23.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
golang.org/x/time v0.7.0 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect
|
google.golang.org/grpc v1.71.1 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
google.golang.org/grpc v1.67.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.35.1 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/libc v1.61.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
|||||||
391
go.sum
@@ -1,82 +1,67 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
|
||||||
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
|
||||||
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
|
||||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
|
||||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
|
||||||
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
|
|
||||||
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
|
|
||||||
cloud.google.com/go/ai v0.8.2 h1:LEaQwqBv+k2ybrcdTtCTc9OPZXoEdcQaGrfvDYS6Bnk=
|
cloud.google.com/go/ai v0.8.2 h1:LEaQwqBv+k2ybrcdTtCTc9OPZXoEdcQaGrfvDYS6Bnk=
|
||||||
cloud.google.com/go/ai v0.8.2/go.mod h1:Wb3EUUGWwB6yHBaUf/+oxUq/6XbCaU1yh0GrwUS8lr4=
|
cloud.google.com/go/ai v0.8.2/go.mod h1:Wb3EUUGWwB6yHBaUf/+oxUq/6XbCaU1yh0GrwUS8lr4=
|
||||||
cloud.google.com/go/aiplatform v1.68.0 h1:EPPqgHDJpBZKRvv+OsB3cr0jYz3EL2pZ+802rBPcG8U=
|
cloud.google.com/go/aiplatform v1.74.0 h1:rE2P5H7FOAFISAZilmdkapbk4CVgwfVs6FDWlhGfuy0=
|
||||||
cloud.google.com/go/aiplatform v1.68.0/go.mod h1:105MFA3svHjC3Oazl7yjXAmIR89LKhRAeNdnDKJczME=
|
cloud.google.com/go/aiplatform v1.74.0/go.mod h1:hVEw30CetNut5FrblYd1AJUWRVSIjoyIvp0EVUh51HA=
|
||||||
cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8=
|
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
|
||||||
cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
|
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
||||||
cloud.google.com/go/compute v1.28.1 h1:XwPcZjgMCnU2tkwY10VleUjSAfpTj9RDn+kGrbYsi8o=
|
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA=
|
||||||
cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU=
|
cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4=
|
||||||
cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g=
|
cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg=
|
||||||
cloud.google.com/go/longrunning v0.6.1 h1:lOLTFxYpr8hcRtcwWir5ITh1PAKUD/sG2lKrTSYjyMc=
|
cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs=
|
||||||
cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0=
|
|
||||||
cloud.google.com/go/vertexai v0.13.1 h1:E6I+eA6vNQxz7/rb0wdILdKg4hFmMNWZLp+dSy9DnEo=
|
cloud.google.com/go/vertexai v0.13.1 h1:E6I+eA6vNQxz7/rb0wdILdKg4hFmMNWZLp+dSy9DnEo=
|
||||||
cloud.google.com/go/vertexai v0.13.1/go.mod h1:25DzKFzP9JByYxcNjJefu/px2dRjcRpCDSdULYL2avI=
|
cloud.google.com/go/vertexai v0.13.1/go.mod h1:25DzKFzP9JByYxcNjJefu/px2dRjcRpCDSdULYL2avI=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d h1:3v1QFdgk450QH+7C+lw1k+olbjK4fKGsrEfnEG/HLkY=
|
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d h1:3v1QFdgk450QH+7C+lw1k+olbjK4fKGsrEfnEG/HLkY=
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d/go.mod h1:2sp0vsMyh5sqmKl5N+ps/cSspqLkoXUlesSzsufIGRU=
|
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d/go.mod h1:2sp0vsMyh5sqmKl5N+ps/cSspqLkoXUlesSzsufIGRU=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
|
||||||
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
|
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/duke-git/lancet/v2 v2.3.2 h1:Cv+uNkx5yGqDSvGc5Vu9eiiZobsPIf0Ng7NGy5hEdow=
|
|
||||||
github.com/duke-git/lancet/v2 v2.3.2/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
|
||||||
github.com/duke-git/lancet/v2 v2.3.3 h1:OhqzNzkbJBS9ZlWLo/C7g+WSAOAAyNj7p9CAiEHurUc=
|
github.com/duke-git/lancet/v2 v2.3.3 h1:OhqzNzkbJBS9ZlWLo/C7g+WSAOAAyNj7p9CAiEHurUc=
|
||||||
github.com/duke-git/lancet/v2 v2.3.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
github.com/duke-git/lancet/v2 v2.3.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
|
||||||
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
|
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
|
||||||
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
|
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
|
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
@@ -89,61 +74,54 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
|||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
|
||||||
|
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
|
||||||
|
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
|
||||||
|
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/generative-ai-go v0.18.0 h1:6ybg9vOCLcI/UpBBYXOTVgvKmcUKFRNj+2Cj3GnebSo=
|
github.com/google/generative-ai-go v0.18.0 h1:6ybg9vOCLcI/UpBBYXOTVgvKmcUKFRNj+2Cj3GnebSo=
|
||||||
github.com/google/generative-ai-go v0.18.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
|
github.com/google/generative-ai-go v0.18.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g=
|
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
|
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
||||||
@@ -152,6 +130,16 @@ github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6Ee
|
|||||||
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
||||||
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
|
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
|
||||||
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
|
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
@@ -163,19 +151,29 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
|||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/liushuangls/go-anthropic/v2 v2.15.0 h1:zpplg7BRV/9FlMmeMPI0eDwhViB0l9SkNrF8ErYlRoQ=
|
||||||
|
github.com/liushuangls/go-anthropic/v2 v2.15.0/go.mod h1:kq2yW3JVy1/rph8u5KzX7F3q95CEpCT2RXp/2nfCmb4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
|
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
|
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
|
||||||
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
|
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
|
||||||
|
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||||
|
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -185,8 +183,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -196,25 +192,30 @@ github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQ
|
|||||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/sashabaranov/go-openai v1.31.0 h1:rGe77x7zUeCjtS2IS7NCY6Tp4bQviXNMhkQM6hz/UC4=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/sashabaranov/go-openai v1.31.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
|
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
|
||||||
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
@@ -225,139 +226,136 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 h1:yMkBS9yViCc7U7yeLzJPM2XizlfdVvBRSmsQDWu6qc0=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0/go.mod h1:n8MR6/liuGB5EmTETUBeU5ZgqMOlqKRxUaqPQBOANZ8=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||||
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
|
||||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
|
||||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||||
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
|
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.200.0 h1:0ytfNWn101is6e9VBoct2wrGDjOi5vn7jw5KtaQgDrU=
|
google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU=
|
||||||
google.golang.org/api v0.200.0/go.mod h1:Tc5u9kcbjO7A8SwGlYj4IiVifJU01UqXtEgDMYmBmV8=
|
google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ=
|
||||||
google.golang.org/api v0.201.0 h1:+7AD9JNM3tREtawRMu8sOjSbb8VYcYXJG/2eEOmfDu0=
|
google.golang.org/genai v1.0.0 h1:9IIZimT9bJm0wiF55VAoGCL8MfOAZcwqRRlxZZ/KSoc=
|
||||||
google.golang.org/api v0.201.0/go.mod h1:HVY0FCHVs89xIW9fzf/pBvOEm+OolHa86G/txFezyq4=
|
google.golang.org/genai v1.0.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms=
|
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240930140551-af27646dc61f h1:jTm13A2itBi3La6yTGqn8bVSrc3ZZ1r8ENHlIXBfnRA=
|
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240930140551-af27646dc61f/go.mod h1:CLGoBuH1VHxAUXVPP8FfPwPEVJB6lz3URE5mY2SuayE=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
|
||||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
|
||||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||||
@@ -383,4 +381,3 @@ modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0
|
|||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
|
|||||||
77
internal/auth/auth.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateTokenPair(user *model.User, secret string, accessExpire, refreshExpire time.Duration) (*TokenPair, error) {
|
||||||
|
// Generate access token
|
||||||
|
accessToken, err := generateToken(user, "access", secret, accessExpire)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
refreshToken, err := generateToken(user, "refresh", secret, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(user *model.User, tokenType, secret string, expire time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: user.Username,
|
||||||
|
Type: tokenType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateToken(tokenString, secret string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, jwt.ErrInvalidKey
|
||||||
|
}
|
||||||
91
internal/cli/cli.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/pkg/store"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/fileutil"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LoadCmd = &cobra.Command{
|
||||||
|
Use: "load",
|
||||||
|
Short: "import user.json -> db",
|
||||||
|
Long: "\nimport user.json -> db",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
db := store.GetDB()
|
||||||
|
var cont int64
|
||||||
|
if err := db.Model(model.User{}).Count(&cont).Error; err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cont == 0 {
|
||||||
|
fmt.Println("创建管理员之后再操作")
|
||||||
|
}
|
||||||
|
if !fileutil.IsExist("./db/user.json") {
|
||||||
|
log.Fatalln("404! user.json is not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := os.Open("./db/user.json")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error opening file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var usermap []map[string]string
|
||||||
|
|
||||||
|
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
|
||||||
|
fmt.Println("解析文件失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, um := range usermap {
|
||||||
|
var name string
|
||||||
|
if um["username"] != "" {
|
||||||
|
name = um["name"]
|
||||||
|
} else if um["name"] == "" {
|
||||||
|
name = um["username"]
|
||||||
|
} else {
|
||||||
|
fmt.Println("获取不到数据")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var user = model.User{
|
||||||
|
Username: name,
|
||||||
|
Name: name,
|
||||||
|
Tokens: []model.Token{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Key: um["token"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
fmt.Printf("\nCreate User %s Error:%s", user.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var SaveCmd = &cobra.Command{
|
||||||
|
Use: "save",
|
||||||
|
Short: "backup user info -> user.json",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// SaveCmd.Flags().StringP("user", "u", "", "Save User")
|
||||||
|
}
|
||||||
62
internal/consts/consts.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
const Logo = `
|
||||||
|
____ _____
|
||||||
|
/ __ \ |_ _|
|
||||||
|
| | | |_ __ ___ _ __ | | ___ __ _ _ __ ___
|
||||||
|
| | | | '_ \ / _ \ '_ \ | | / _ \/ _' | '_ ' _ \
|
||||||
|
| |__| | |_) | __/ | | | | || __/ (_| | | | | | |
|
||||||
|
\____/| .__/ \___|_| |_| \_/ \___|\__,_|_| |_| |_|
|
||||||
|
| |
|
||||||
|
|_|
|
||||||
|
|
||||||
|
https://github.com/mirrors2/openteam
|
||||||
|
---------------------------------------------------
|
||||||
|
`
|
||||||
|
|
||||||
|
const SecretKey = "openteam"
|
||||||
|
|
||||||
|
const Day = 24 * 60 * 60 // day := 86400
|
||||||
|
|
||||||
|
type UserRole int
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleUser UserRole = iota * 10
|
||||||
|
RoleAdmin
|
||||||
|
RoleRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusDisabled = iota
|
||||||
|
StatusEnabled
|
||||||
|
StatusExpired // 过期
|
||||||
|
StatusExhausted // 耗尽
|
||||||
|
|
||||||
|
StatusDeleted = -1
|
||||||
|
)
|
||||||
|
const (
|
||||||
|
Limited = iota
|
||||||
|
Unlimited
|
||||||
|
UnlimitedQuota = 999999
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserNotFound = gorm.ErrRecordNotFound
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenOrClose(status bool) int {
|
||||||
|
if status {
|
||||||
|
return StatusEnabled
|
||||||
|
}
|
||||||
|
return StatusDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// type DBType int
|
||||||
|
|
||||||
|
// const (
|
||||||
|
// DBTypeMySQL DBType = iota
|
||||||
|
// DBTypePostgreSQL
|
||||||
|
// DBTypeSQLite
|
||||||
|
// )
|
||||||
177
internal/controller/apikey.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/consts"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/internal/utils"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a Api) CreateApiKey(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newkey := new(model.ApiKey)
|
||||||
|
err := c.ShouldBind(newkey)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
}
|
||||||
|
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
|
||||||
|
sma, err := utils.FetchKeyModel(a.db, newkey)
|
||||||
|
if err == nil && len(sma) > 0 {
|
||||||
|
newkey.SupportModelsArray = sma
|
||||||
|
var buf = new(bytes.Buffer)
|
||||||
|
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
|
||||||
|
newkey.SupportModels = utils.ToPtr(buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.keyService.CreateApiKey(c, newkey)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
} else {
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) GetApiKey(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := a.keyService.GetApiKey(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
} else {
|
||||||
|
dto.Success(c, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) ListApiKey(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
active := c.QueryArray("active[]")
|
||||||
|
if !slice.ContainSubSlice([]string{"true", "false"}, active) {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, total, err := a.keyService.ListApiKey(c, limit, offset, active)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
} else {
|
||||||
|
for _, key := range keys {
|
||||||
|
str := *key.ApiKey
|
||||||
|
slen := len(str)
|
||||||
|
if slen > 20 {
|
||||||
|
slen = 20
|
||||||
|
}
|
||||||
|
str = str[:slen]
|
||||||
|
key.ApiKey = &str
|
||||||
|
|
||||||
|
var sma []string
|
||||||
|
json.NewDecoder(strings.NewReader(*key.SupportModels)).Decode(&sma) //nolint:errcheck
|
||||||
|
key.SupportModelsArray = sma
|
||||||
|
}
|
||||||
|
dto.Success(c, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"keys": keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) DeleteApiKey(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var batchid dto.BatchIDRequest
|
||||||
|
err := c.ShouldBind(&batchid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.keyService.DeleteApiKey(c, batchid.IDs)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
} else {
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) UpdateApiKey(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.ApiKey
|
||||||
|
err := c.ShouldBind(&req)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.keyService.UpdateApiKey(c, &req)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
} else {
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) ApiKeyOption(c *gin.Context) {
|
||||||
|
role := c.MustGet("user_role").(*consts.UserRole)
|
||||||
|
if *role < consts.RoleAdmin {
|
||||||
|
dto.Fail(c, 403, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
option := strings.ToLower(c.Param("option"))
|
||||||
|
var batchid dto.BatchIDRequest
|
||||||
|
err := c.ShouldBind(&batchid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch option {
|
||||||
|
case "enable":
|
||||||
|
err = a.keyService.EnableApiKey(c, batchid.IDs)
|
||||||
|
case "disable":
|
||||||
|
err = a.keyService.DisableApiKey(c, batchid.IDs)
|
||||||
|
case "delete":
|
||||||
|
err = a.keyService.DeleteApiKey(c, batchid.IDs)
|
||||||
|
default:
|
||||||
|
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
30
internal/controller/init.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/service"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Api struct {
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
userService *service.UserServiceImpl
|
||||||
|
tokenService *service.TokenServiceImpl
|
||||||
|
keyService *service.ApiKeyServiceImpl
|
||||||
|
webAuthService *service.WebAuthnService
|
||||||
|
usageService *service.UsageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, webAuthService *service.WebAuthnService, usageService *service.UsageService) *Api {
|
||||||
|
return &Api{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
userService: userService,
|
||||||
|
tokenService: tokenService,
|
||||||
|
keyService: keyService,
|
||||||
|
webAuthService: webAuthService,
|
||||||
|
usageService: usageService,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/controller/proxy/chat_proxy.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/llm"
|
||||||
|
"opencatd-open/llm/claude/v2"
|
||||||
|
"opencatd-open/llm/google/v2"
|
||||||
|
"opencatd-open/llm/openai_compatible"
|
||||||
|
"opencatd-open/pkg/tokenizer"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Proxy) ChatHandler(c *gin.Context) {
|
||||||
|
user := c.MustGet("user").(*model.User)
|
||||||
|
if user == nil {
|
||||||
|
dto.WrapErrorAsOpenAI(c, 401, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatreq llm.ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&chatreq); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.SelectApiKey(chatreq.Model)
|
||||||
|
if err != nil {
|
||||||
|
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var llm llm.LLM
|
||||||
|
switch *h.apikey.ApiType {
|
||||||
|
case "claude":
|
||||||
|
llm, err = claude.NewClaude(h.apikey)
|
||||||
|
case "gemini":
|
||||||
|
llm, err = google.NewGemini(c, h.apikey)
|
||||||
|
case "openai", "azure", "github":
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
llm, err = openai_compatible.NewOpenAICompatible(h.apikey)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !chatreq.Stream {
|
||||||
|
resp, err := llm.Chat(c, chatreq)
|
||||||
|
if err != nil {
|
||||||
|
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
datachan, err := llm.StreamChat(c, chatreq)
|
||||||
|
if err != nil {
|
||||||
|
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
||||||
|
}
|
||||||
|
for data := range datachan {
|
||||||
|
c.SSEvent("", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
llmusage := llm.GetTokenUsage()
|
||||||
|
llmusage.User = user
|
||||||
|
llmusage.TokenID = c.GetInt64("token_id")
|
||||||
|
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens+llmusage.ToolsTokens, llmusage.CompletionTokens)
|
||||||
|
|
||||||
|
h.SendUsage(llmusage)
|
||||||
|
defer fmt.Println("cost:", cost, "prompt_tokens:", llmusage.PromptTokens, "completion_tokens:", llmusage.CompletionTokens, "total_tokens:", llmusage.TotalTokens)
|
||||||
|
|
||||||
|
}
|
||||||
58
internal/controller/proxy/models.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Proxy) HandleModels(c *gin.Context) {
|
||||||
|
models, err := p.getModelCache()
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type _model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
var ms []_model
|
||||||
|
for _, model := range models {
|
||||||
|
ms = append(ms, _model{ID: model})
|
||||||
|
}
|
||||||
|
dto.Success(c, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) setModelCache() error {
|
||||||
|
apikeys, err := p.apiKeyDao.FindKeys(nil)
|
||||||
|
models := make(map[string]bool)
|
||||||
|
if err == nil && len(apikeys) > 0 {
|
||||||
|
for _, k := range apikeys {
|
||||||
|
if len(k.SupportModelsArray) > 0 {
|
||||||
|
for _, sm := range k.SupportModelsArray {
|
||||||
|
models[sm] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var sma []string
|
||||||
|
json.Unmarshal([]byte(*k.SupportModels), &sma) // nolint:errCheck
|
||||||
|
for _, sm := range sma {
|
||||||
|
models[sm] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("empty data")
|
||||||
|
}
|
||||||
|
var support_models []string
|
||||||
|
for m, _ := range models {
|
||||||
|
support_models = append(support_models, m)
|
||||||
|
}
|
||||||
|
return p.cache.Set("models", support_models)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) getModelCache() ([]string, error) {
|
||||||
|
models, err := p.cache.Get("models")
|
||||||
|
return models.([]string), err
|
||||||
|
}
|
||||||
399
internal/controller/proxy/proxy.go
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/internal/utils"
|
||||||
|
"opencatd-open/llm"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"opencatd-open/pkg/tokenizer"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bluele/gcache"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Proxy struct {
|
||||||
|
ctx context.Context
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
wg *sync.WaitGroup
|
||||||
|
usageChan chan *llm.TokenUsage // 用于异步处理的channel
|
||||||
|
apikey *model.ApiKey
|
||||||
|
httpClient *http.Client
|
||||||
|
cache gcache.Cache
|
||||||
|
|
||||||
|
userDAO *dao.UserDAO
|
||||||
|
apiKeyDao *dao.ApiKeyDAO
|
||||||
|
tokenDAO *dao.TokenDAO
|
||||||
|
usageDAO *dao.UsageDAO
|
||||||
|
dailyUsageDAO *dao.DailyUsageDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, tokenDAO *dao.TokenDAO, usageDAO *dao.UsageDAO, dailyUsageDAO *dao.DailyUsageDAO) *Proxy {
|
||||||
|
client := http.DefaultClient
|
||||||
|
if os.Getenv("LOCAL_PROXY") != "" {
|
||||||
|
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||||
|
if err == nil {
|
||||||
|
tr := &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyUrl),
|
||||||
|
}
|
||||||
|
client.Transport = tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
np := &Proxy{
|
||||||
|
ctx: ctx,
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
wg: wg,
|
||||||
|
httpClient: client,
|
||||||
|
cache: gcache.New(1).Build(),
|
||||||
|
usageChan: make(chan *llm.TokenUsage, cfg.UsageChanSize),
|
||||||
|
userDAO: userDAO,
|
||||||
|
apiKeyDao: apiKeyDAO,
|
||||||
|
tokenDAO: tokenDAO,
|
||||||
|
usageDAO: usageDAO,
|
||||||
|
dailyUsageDAO: dailyUsageDAO,
|
||||||
|
}
|
||||||
|
|
||||||
|
go np.ProcessUsage()
|
||||||
|
go np.ScheduleTask()
|
||||||
|
np.setModelCache()
|
||||||
|
return np
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) HandleProxy(c *gin.Context) {
|
||||||
|
if c.Request.URL.Path == "/v1/chat/completions" {
|
||||||
|
p.ChatHandler(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
|
||||||
|
p.ProxyClaude(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) SendUsage(usage *llm.TokenUsage) {
|
||||||
|
select {
|
||||||
|
case p.usageChan <- usage:
|
||||||
|
default:
|
||||||
|
log.Println("usage channel is full, skip processing")
|
||||||
|
bj, _ := json.Marshal(usage)
|
||||||
|
log.Println(string(bj))
|
||||||
|
//TODO: send to a queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) ProcessUsage() {
|
||||||
|
for i := 0; i < p.cfg.UsageWorker; i++ {
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer p.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case usage, ok := <-p.usageChan:
|
||||||
|
if !ok {
|
||||||
|
// channel 关闭,退出程序
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := p.Do(usage)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("process usage error: %v\n", err)
|
||||||
|
}
|
||||||
|
case <-p.ctx.Done():
|
||||||
|
// close(s.usageChan)
|
||||||
|
// for usage := range s.usageChan {
|
||||||
|
// if err := s.Do(usage); err != nil {
|
||||||
|
// fmt.Printf("[close event]process usage error: %v\n", err)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case usage, ok := <-p.usageChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.Do(usage); err != nil {
|
||||||
|
fmt.Printf("[close event]process usage error: %v\n", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Printf("usageChan is empty,usage worker %d done\n", i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}(i)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) Do(llmusage *llm.TokenUsage) error {
|
||||||
|
err := p.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
now := time.Now()
|
||||||
|
today, _ := time.Parse("2006-01-02", now.Format("2006-01-02"))
|
||||||
|
|
||||||
|
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens, llmusage.CompletionTokens)
|
||||||
|
token, err := p.tokenDAO.GetByID(p.ctx, llmusage.TokenID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &model.Usage{
|
||||||
|
UserID: llmusage.User.ID,
|
||||||
|
TokenID: llmusage.TokenID,
|
||||||
|
Date: now,
|
||||||
|
Model: llmusage.Model,
|
||||||
|
Stream: llmusage.Stream,
|
||||||
|
PromptTokens: llmusage.PromptTokens,
|
||||||
|
CompletionTokens: llmusage.CompletionTokens,
|
||||||
|
TotalTokens: llmusage.TotalTokens,
|
||||||
|
Cost: fmt.Sprintf("%.8f", cost),
|
||||||
|
}
|
||||||
|
// 1. 记录使用记录
|
||||||
|
if err := tx.WithContext(p.ctx).Create(usage).Error; err != nil {
|
||||||
|
return fmt.Errorf("create usage error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 更新每日统计
|
||||||
|
var dailyUsage model.DailyUsage
|
||||||
|
result := tx.WithContext(p.ctx).Where("user_id = ? and date = ?", llmusage.User.ID, today).First(&dailyUsage)
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
dailyUsage.UserID = llmusage.User.ID
|
||||||
|
dailyUsage.TokenID = llmusage.TokenID
|
||||||
|
dailyUsage.Date = today
|
||||||
|
dailyUsage.Model = llmusage.Model
|
||||||
|
dailyUsage.Stream = llmusage.Stream
|
||||||
|
dailyUsage.PromptTokens = llmusage.PromptTokens
|
||||||
|
dailyUsage.CompletionTokens = llmusage.CompletionTokens
|
||||||
|
dailyUsage.TotalTokens = llmusage.TotalTokens
|
||||||
|
dailyUsage.Cost = fmt.Sprintf("%.8f", cost)
|
||||||
|
if err := tx.WithContext(p.ctx).Create(&dailyUsage).Error; err != nil {
|
||||||
|
return fmt.Errorf("create daily usage error: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.WithContext(p.ctx).Model(&model.DailyUsage{}).Where("user_id = ? and date = ?", llmusage.User.ID, today).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"prompt_tokens": gorm.Expr("prompt_tokens + ?", llmusage.PromptTokens),
|
||||||
|
"completion_tokens": gorm.Expr("completion_tokens + ?", llmusage.CompletionTokens),
|
||||||
|
"total_tokens": gorm.Expr("total_tokens + ?", llmusage.TotalTokens),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("update daily usage error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新用户额度
|
||||||
|
if *llmusage.User.UnlimitedQuota {
|
||||||
|
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
||||||
|
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
||||||
|
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//4 . 更新token额度
|
||||||
|
if *token.UnlimitedQuota {
|
||||||
|
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
||||||
|
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
||||||
|
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) SelectApiKey(model string) error {
|
||||||
|
akpikeys, err := p.apiKeyDao.FindApiKeysBySupportModel(p.db, model)
|
||||||
|
if err != nil || len(akpikeys) == 0 {
|
||||||
|
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") {
|
||||||
|
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "openai"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
akpikeys = append(akpikeys, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(model, "gemini") {
|
||||||
|
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "gemini"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
akpikeys = append(akpikeys, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(model, "claude") {
|
||||||
|
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "claude"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
akpikeys = append(akpikeys, keys...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(akpikeys) == 0 {
|
||||||
|
return errors.New("no available apikey")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(akpikeys) == 1 {
|
||||||
|
p.apikey = &akpikeys[0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
length := len(akpikeys) - 1
|
||||||
|
|
||||||
|
p.apikey = &akpikeys[rand.Intn(length)]
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) updateSupportModel() {
|
||||||
|
|
||||||
|
keys, err := p.apiKeyDao.FindKeys(map[string]interface{}{"apitype in ?": []string{"openai", "azure", "claude"}})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
var supportModels []string
|
||||||
|
if *key.ApiType == "openai" || *key.ApiType == "azure" {
|
||||||
|
supportModels, err = p.getOpenAISupportModels(key)
|
||||||
|
}
|
||||||
|
if *key.ApiType == "claude" {
|
||||||
|
supportModels, err = p.getClaudeSupportModels(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(supportModels) == 0 {
|
||||||
|
continue
|
||||||
|
|
||||||
|
}
|
||||||
|
if p.cfg.DB_Type == "sqlite" {
|
||||||
|
bytejson, _ := json.Marshal(supportModels)
|
||||||
|
if err := p.db.Model(&model.ApiKey{}).Where("id = ?", key.ID).UpdateColumn("support_models", string(bytejson)).Error; err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
} else if p.cfg.DB_Type == "postgres" {
|
||||||
|
if err := p.db.Model(&model.ApiKey{}).Where("id = ?", key.ID).UpdateColumn("support_models", pq.StringArray(supportModels)).Error; err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) ScheduleTask() {
|
||||||
|
|
||||||
|
func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Duration(p.cfg.TaskTimeInterval) * time.Minute):
|
||||||
|
p.updateSupportModel()
|
||||||
|
case <-time.After(time.Hour * 12):
|
||||||
|
if err := p.setModelCache(); err != nil {
|
||||||
|
fmt.Println("refrash model cache err:", err)
|
||||||
|
}
|
||||||
|
case <-p.ctx.Done():
|
||||||
|
fmt.Println("schedule task done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) getOpenAISupportModels(apikey model.ApiKey) ([]string, error) {
|
||||||
|
openaiModelsUrl := "https://api.openai.com/v1/models"
|
||||||
|
// https://learn.microsoft.com/zh-cn/rest/api/azureopenai/models/list?view=rest-azureopenai-2025-02-01-preview&tabs=HTTP
|
||||||
|
azureModelsUrl := "/openai/deployments?api-version=2022-12-01"
|
||||||
|
|
||||||
|
var supportModels []string
|
||||||
|
var req *http.Request
|
||||||
|
if *apikey.ApiType == "azure" {
|
||||||
|
if strings.HasSuffix(*apikey.Endpoint, "/") {
|
||||||
|
apikey.Endpoint = utils.ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("api-key", *apikey.ApiKey)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest("GET", openaiModelsUrl, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+*apikey.ApiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
bytesbody, _ := io.ReadAll(resp.Body)
|
||||||
|
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
||||||
|
for _, v := range result {
|
||||||
|
model := v.Str
|
||||||
|
model = strings.Replace(model, "-35-", "-3.5-", -1)
|
||||||
|
model = strings.Replace(model, "-41-", "-4.1-", -1)
|
||||||
|
supportModels = append(supportModels, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return supportModels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) getClaudeSupportModels(apikey model.ApiKey) ([]string, error) {
|
||||||
|
// https://docs.anthropic.com/en/api/models-list
|
||||||
|
claudemodelsUrl := "https://api.anthropic.com/v1/models"
|
||||||
|
var supportModels []string
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", claudemodelsUrl, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", *apikey.ApiKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
bytesbody, _ := io.ReadAll(resp.Body)
|
||||||
|
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
||||||
|
for _, v := range result {
|
||||||
|
supportModels = append(supportModels, v.Str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return supportModels, nil
|
||||||
|
}
|
||||||
14
internal/controller/proxy/proxy_claude.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Proxy) ProxyClaude(c *gin.Context) {
|
||||||
|
fmt.Println(c.Request.URL.String())
|
||||||
|
data, _ := io.ReadAll(c.Request.Body)
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
53
internal/controller/team/middleware.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/consts"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Team) AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.URL.Path == "/1/users/init" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authtoken := c.GetHeader("Authorization")
|
||||||
|
if authtoken == "" || len(authtoken) <= 7 || authtoken[:7] != "Bearer " {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authtoken = authtoken[7:]
|
||||||
|
token, err := h.tokenService.GetByKey(c, authtoken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
if token.Name != "default" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "only default token can access"})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
if token.User.Status != consts.StatusEnabled {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "user is disabled"})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
c.Set("local_user", true)
|
||||||
|
c.Set("token", token)
|
||||||
|
|
||||||
|
// 可以在这里对 token 进行验证并检查权限
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
config := cors.DefaultConfig()
|
||||||
|
config.AllowAllOrigins = true
|
||||||
|
config.AllowCredentials = true
|
||||||
|
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||||
|
config.AllowHeaders = []string{"*"}
|
||||||
|
return cors.New(config)
|
||||||
|
}
|
||||||
563
internal/controller/team/team.go
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"opencatd-open/internal/consts"
|
||||||
|
dto "opencatd-open/internal/dto/team"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
service "opencatd-open/internal/service/team"
|
||||||
|
"opencatd-open/internal/utils"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
db *gorm.DB
|
||||||
|
userService service.UserService
|
||||||
|
tokenService service.TokenService
|
||||||
|
keyService service.ApiKeyService
|
||||||
|
usageService service.UsageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTeam(userService service.UserService, tokenService service.TokenService, keyService service.ApiKeyService, usageService service.UsageService) *Team {
|
||||||
|
return &Team{
|
||||||
|
userService: userService,
|
||||||
|
tokenService: tokenService,
|
||||||
|
keyService: keyService,
|
||||||
|
usageService: usageService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initadmin
|
||||||
|
func (h *Team) InitAdmin(c *gin.Context) {
|
||||||
|
admin, err := h.userService.GetUser(c, 1)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
user := &model.User{
|
||||||
|
Name: "root",
|
||||||
|
Username: "root",
|
||||||
|
Password: "openteam",
|
||||||
|
Role: utils.ToPtr(consts.RoleRoot),
|
||||||
|
Tokens: []model.Token{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
||||||
|
UnlimitedQuota: utils.ToPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := h.userService.CreateUser(c, user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result = dto.UserInfo{
|
||||||
|
ID: user.ID,
|
||||||
|
Name: user.Username,
|
||||||
|
Token: user.Tokens[0].Key,
|
||||||
|
Status: utils.ToPtr(user.Status == consts.StatusEnabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if admin != nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "super user already exists, use cli to reset password",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) Me(c *gin.Context) {
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.UserInfo{
|
||||||
|
ID: userToken.UserID,
|
||||||
|
Name: userToken.User.Name,
|
||||||
|
Token: userToken.Key,
|
||||||
|
Status: utils.ToPtr(userToken.User.Status == consts.StatusEnabled),
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser 创建用户
|
||||||
|
func (h *Team) CreateUser(c *gin.Context) {
|
||||||
|
var userReq dto.UserInfo
|
||||||
|
if err := c.ShouldBindJSON(&userReq); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin { // 普通用户只能创建自己的token
|
||||||
|
create := &model.Token{
|
||||||
|
Name: userReq.Name,
|
||||||
|
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
||||||
|
}
|
||||||
|
if userReq.Token != "" {
|
||||||
|
_key := strings.ReplaceAll(userReq.Token, "-", "")
|
||||||
|
create.Key = "sk-team-" + strings.ReplaceAll(_key, " ", "")
|
||||||
|
}
|
||||||
|
if err := h.tokenService.Create(c.Request.Context(), create); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
user := &model.User{
|
||||||
|
Name: userReq.Name,
|
||||||
|
Username: userReq.Name,
|
||||||
|
Role: utils.ToPtr(consts.RoleUser),
|
||||||
|
Tokens: []model.Token{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认角色为普通用户
|
||||||
|
if err := h.userService.CreateUser(c.Request.Context(), user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser 获取用户信息
|
||||||
|
func (h *Team) GetUser(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userService.GetUser(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser 更新用户信息
|
||||||
|
func (h *Team) UpdateUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := c.ShouldBindJSON(&user); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
|
||||||
|
operatorID := userToken.UserID // 假设从上下文中获取操作者ID
|
||||||
|
if err := h.userService.UpdateUser(c.Request.Context(), &user, operatorID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser 删除用户
|
||||||
|
func (h *Team) DeleteUser(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin { // 用户只能删除自己的token
|
||||||
|
err := h.tokenService.Delete(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := h.userService.DeleteUser(c, id, userToken.UserID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) ListUsages(c *gin.Context) {
|
||||||
|
fromStr := c.Query("from")
|
||||||
|
toStr := c.Query("to")
|
||||||
|
|
||||||
|
var from, to time.Time
|
||||||
|
loc, _ := time.LoadLocation("Local")
|
||||||
|
|
||||||
|
var listUsage []*dto.UsageInfo
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if fromStr != "" && toStr != "" {
|
||||||
|
|
||||||
|
from, err = time.Parse("2006-01-02", fromStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to, err = time.Parse("2006-01-02", toStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid to date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
year, month, _ := time.Now().In(loc).Date()
|
||||||
|
from = time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
||||||
|
to = from.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _ := c.Get("token")
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin {
|
||||||
|
listUsage, err = h.usageService.ListByDateRange(c.Request.Context(), from, to, map[string]interface{}{"user_id": userToken.UserID})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listUsage, err = h.usageService.ListByDateRange(c.Request.Context(), from, to, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, listUsage)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers 获取用户列表
|
||||||
|
func (h *Team) ListUsers(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
active := c.DefaultQuery("active", "")
|
||||||
|
|
||||||
|
if !slices.Contains([]string{"true", "false", ""}, active) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid active value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin { // 用户只能获取自己的token
|
||||||
|
tokens, _, err := h.tokenService.Lists(c, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userDTOs []dto.UserInfo
|
||||||
|
for _, token := range tokens {
|
||||||
|
userDTOs = append(userDTOs, dto.UserInfo{
|
||||||
|
ID: token.User.ID,
|
||||||
|
Name: token.User.Name,
|
||||||
|
Token: token.Key,
|
||||||
|
Status: utils.ToPtr(token.User.Status == consts.StatusEnabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, userDTOs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := h.userService.ListUsers(c, limit, offset, active)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDTOs []dto.UserInfo
|
||||||
|
for _, user := range users {
|
||||||
|
useres := dto.UserInfo{
|
||||||
|
ID: user.ID,
|
||||||
|
Name: user.Name,
|
||||||
|
|
||||||
|
Status: utils.ToPtr(user.Status == consts.StatusEnabled),
|
||||||
|
}
|
||||||
|
if len(user.Tokens) > 0 {
|
||||||
|
useres.Token = user.Tokens[0].Key
|
||||||
|
}
|
||||||
|
userDTOs = append(userDTOs, useres)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDTOs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) ResetUserToken(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
|
||||||
|
findtoken, err := h.tokenService.GetByUserID(c, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
findtoken.Key = "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
|
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin { // 非管理员只能修改自己的token
|
||||||
|
if *userToken.User.Role <= *findtoken.User.Role || userToken.UserID != findtoken.UserID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.tokenService.UpdateWithCondition(c, findtoken, map[string]interface{}{"user_id": userToken.UserID}, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := h.tokenService.Update(c, findtoken); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.UserInfo{
|
||||||
|
ID: findtoken.User.ID,
|
||||||
|
Name: findtoken.User.Name,
|
||||||
|
Token: findtoken.Key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) CreateKey(c *gin.Context) {
|
||||||
|
token, exists := c.Get("token")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userToken := token.(*model.Token)
|
||||||
|
if *userToken.User.Role < consts.RoleAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var key dto.ApiKeyInfo
|
||||||
|
if err := c.ShouldBindJSON(&key); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.keyService.Create(&model.ApiKey{
|
||||||
|
Name: utils.ToPtr(key.Name),
|
||||||
|
ApiType: utils.ToPtr(key.ApiType),
|
||||||
|
ApiKey: utils.ToPtr(key.Key),
|
||||||
|
Endpoint: utils.ToPtr(key.Endpoint),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) ListKeys(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
active := c.Query("active")
|
||||||
|
if !slice.Contain([]string{"true", "false", ""}, active) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid active value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := h.keyService.List(limit, offset, active)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysDTO []dto.ApiKeyInfo
|
||||||
|
for _, key := range keys {
|
||||||
|
keylength := len(*key.ApiKey) / 3
|
||||||
|
if keylength < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key length"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keysDTO = append(keysDTO, dto.ApiKeyInfo{
|
||||||
|
ID: int(key.ID),
|
||||||
|
Name: *key.Name,
|
||||||
|
ApiType: *key.ApiType,
|
||||||
|
Endpoint: *key.Endpoint,
|
||||||
|
Key: *key.ApiKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, keysDTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) UpdateKey(c *gin.Context) {
|
||||||
|
// 1. 获取并验证ID
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解析请求体
|
||||||
|
var updateKey dto.ApiKeyInfo // 更明确的命名
|
||||||
|
if err := c.ShouldBindJSON(&updateKey); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取现有记录
|
||||||
|
existingKey, err := h.keyService.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 使用 UpdateFields 方法统一处理字段更新
|
||||||
|
updatedKey := updateKey.UpdateFields(existingKey)
|
||||||
|
|
||||||
|
// 5. 保存更新
|
||||||
|
if err := h.keyService.Update(updatedKey); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, updatedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Team) DeleteKey(c *gin.Context) {
|
||||||
|
// 1. 获取并验证ID
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 删除记录
|
||||||
|
if err := h.keyService.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改密码
|
||||||
|
func (h *Team) ChangePassword(c *gin.Context) {
|
||||||
|
userID := c.GetInt64("userID") // 假设从上下文中获取用户ID
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"oldPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userService.ChangePassword(c.Request.Context(), userID, req.OldPassword, req.NewPassword); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword 重置密码
|
||||||
|
func (h *Team) ResetPassword(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
||||||
|
if err := h.userService.ResetPassword(c.Request.Context(), id, operatorID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password reset successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableUser 启用用户
|
||||||
|
func (h *Team) EnableUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
||||||
|
|
||||||
|
if err := h.userService.BatchEnableUsers(c, []int64{id}, operatorID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user enabled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableUser 禁用用户
|
||||||
|
func (h *Team) DisableUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
||||||
|
if err := h.userService.BatchDisableUsers(c.Request.Context(), []int64{id}, operatorID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user disabled successfully"})
|
||||||
|
}
|
||||||
217
internal/controller/user.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/internal/utils"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a Api) Register(c *gin.Context) {
|
||||||
|
req := new(dto.User)
|
||||||
|
err := c.ShouldBind(&req)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userService.Register(c, &model.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) Login(c *gin.Context) {
|
||||||
|
req := new(dto.User)
|
||||||
|
err := c.ShouldBind(&req)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := a.userService.Login(c, req)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
dto.Success(c, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) Profile(c *gin.Context) {
|
||||||
|
user, err := a.userService.Profile(c)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
dto.Success(c, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) UpdateProfile(c *gin.Context) {
|
||||||
|
var user = model.User{}
|
||||||
|
err := c.ShouldBind(&user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userService.Update(c, &model.User{Name: user.Name, Username: user.Username, Email: user.Email})
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) UpdatePassword(c *gin.Context) {
|
||||||
|
var passwd dto.ChangePassword
|
||||||
|
err := c.ShouldBind(&passwd)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_user := c.MustGet("user").(*model.User)
|
||||||
|
if _user.Password == "" {
|
||||||
|
hashpass, err := utils.HashPassword(passwd.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_user.Password = hashpass
|
||||||
|
} else {
|
||||||
|
if !utils.CheckPassword(_user.Password, passwd.Password) {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, "password not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashpass, err := utils.HashPassword(passwd.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_user.Password = hashpass
|
||||||
|
}
|
||||||
|
err = a.userService.Update(c, _user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) ListUser(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
active := c.QueryArray("active[]")
|
||||||
|
if !slice.ContainSubSlice([]string{"true", "false", ""}, active) {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, total, err := a.userService.List(c, limit, offset, active)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, gin.H{
|
||||||
|
"users": users,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) CreateUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
err := c.ShouldBind(&user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userService.Create(c, &user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) GetUser(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
user, err := a.userService.GetByID(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) EditUser(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
var user model.User
|
||||||
|
err := c.ShouldBind(&user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ID = int64(id)
|
||||||
|
err = a.userService.Update(c, &user)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) DeleteUser(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
err := a.userService.Delete(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) UserOption(c *gin.Context) {
|
||||||
|
option := strings.ToLower(c.Param("option"))
|
||||||
|
var batchid dto.BatchIDRequest
|
||||||
|
err := c.ShouldBind(&batchid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch option {
|
||||||
|
case "enable":
|
||||||
|
err = a.userService.BatchEnable(c, batchid.IDs)
|
||||||
|
case "disable":
|
||||||
|
err = a.userService.BatchDisable(c, batchid.IDs)
|
||||||
|
case "delete":
|
||||||
|
err = a.userService.BatchDelete(c, batchid.IDs)
|
||||||
|
default:
|
||||||
|
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
|
||||||
|
}
|
||||||
218
internal/controller/user_token.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
"opencatd-open/internal/model"
|
||||||
|
"opencatd-open/internal/utils"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a Api) CreateToken(c *gin.Context) {
|
||||||
|
userid := c.GetInt64("user_id")
|
||||||
|
user, err := a.userService.GetByID(c, userid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(user.Tokens) >= 20 {
|
||||||
|
dto.Fail(c, http.StatusForbidden, "user has reached the maximum number of tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token model.Token
|
||||||
|
err = c.ShouldBindJSON(&token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.UserID = userid
|
||||||
|
|
||||||
|
err = a.tokenService.CreateToken(c, &token)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) ListToken(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
active := c.QueryArray("active[]")
|
||||||
|
if !slice.ContainSubSlice([]string{"true", "false"}, active) {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, total, err := a.tokenService.ListToken(c, limit, offset, active)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"tokens": tokens,
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) GetToken(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
|
||||||
|
token, err := a.tokenService.GetToken(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) ResetToken(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := a.tokenService.GetToken(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == nil {
|
||||||
|
dto.Fail(c, http.StatusNotFound, "token not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.UsedQuota = utils.ToPtr(float64(0))
|
||||||
|
|
||||||
|
err = a.tokenService.UpdateToken(c, token)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) UpdateToken(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token model.Token
|
||||||
|
err = c.ShouldBindJSON(&token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.ID = id
|
||||||
|
if token.UserID == 0 {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, "user_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var _token *model.Token
|
||||||
|
|
||||||
|
user, err := a.userService.GetByID(c, token.UserID)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(user.Tokens) == 0 {
|
||||||
|
dto.Fail(c, http.StatusForbidden, "user has no tokens")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
if findtoken, ok := slice.Find(user.Tokens,
|
||||||
|
func(idx int, t model.Token) bool {
|
||||||
|
return t.ID == id
|
||||||
|
}); ok {
|
||||||
|
_token = findtoken
|
||||||
|
_token.User = user
|
||||||
|
} else {
|
||||||
|
dto.Fail(c, http.StatusForbidden, "user has no tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 更新_token信息
|
||||||
|
if token.Name != "" {
|
||||||
|
_token.Name = token.Name
|
||||||
|
}
|
||||||
|
if token.Key != "" {
|
||||||
|
_token.Key = token.Key
|
||||||
|
}
|
||||||
|
if token.Active != nil {
|
||||||
|
_token.Active = token.Active
|
||||||
|
}
|
||||||
|
if token.Quota != nil {
|
||||||
|
_token.Quota = token.Quota
|
||||||
|
}
|
||||||
|
if token.UnlimitedQuota != nil {
|
||||||
|
_token.UnlimitedQuota = token.UnlimitedQuota
|
||||||
|
}
|
||||||
|
if token.ExpiredAt != nil {
|
||||||
|
_token.ExpiredAt = token.ExpiredAt
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.tokenService.UpdateToken(c, _token)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) DeleteToken(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.tokenService.DeleteToken(c, id)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Api) TokenOption(c *gin.Context) {
|
||||||
|
option := strings.ToLower(c.Param("option"))
|
||||||
|
var batchid dto.BatchIDRequest
|
||||||
|
err := c.ShouldBind(&batchid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if batchid.UserID == nil {
|
||||||
|
dto.Fail(c, 400, "user_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch option {
|
||||||
|
case "enable":
|
||||||
|
err = a.tokenService.EnableTokens(c, *batchid.UserID, batchid.IDs)
|
||||||
|
case "disable":
|
||||||
|
err = a.tokenService.DisableTokens(c, *batchid.UserID, batchid.IDs)
|
||||||
|
case "delete":
|
||||||
|
err = a.tokenService.DeleteTokens(c, *batchid.UserID, batchid.IDs)
|
||||||
|
default:
|
||||||
|
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, nil)
|
||||||
|
}
|
||||||
108
internal/controller/webauth.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"opencatd-open/internal/auth"
|
||||||
|
"opencatd-open/internal/consts"
|
||||||
|
"opencatd-open/internal/dto"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Api) PasskeyCreateBegin(c *gin.Context) {
|
||||||
|
userid := c.GetInt64("user_id")
|
||||||
|
cred, err := a.webAuthService.BeginRegistration(userid)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Api) PasskeyCreateFinish(c *gin.Context) {
|
||||||
|
userid := c.GetInt64("user_id")
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("User-%d-%d", userid, time.Now().Unix())
|
||||||
|
}
|
||||||
|
// var body protocol.CredentialCreationResponse
|
||||||
|
// if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
// dto.Fail(c, 400, err.Error())
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 获取用户凭证
|
||||||
|
cred, err := a.webAuthService.FinishRegistration(userid, c.Request, name)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Api) ListPasskey(c *gin.Context) {
|
||||||
|
passkeys, err := a.webAuthService.ListPasskeys(c.GetInt64("user_id"))
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var passkeysDto []dto.Passkey
|
||||||
|
for _, passkey := range passkeys {
|
||||||
|
passkeysDto = append(passkeysDto, dto.Passkey{
|
||||||
|
ID: passkey.ID,
|
||||||
|
Name: passkey.Name,
|
||||||
|
DeviceType: passkey.DeviceType,
|
||||||
|
SignCount: passkey.SignCount,
|
||||||
|
LastUsedAt: passkey.LastUsedAt,
|
||||||
|
CreatedAt: passkey.CreatedAt,
|
||||||
|
UpdatedAt: passkey.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.Success(c, passkeysDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Api) DeletePasskey(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = a.webAuthService.DeletePasskey(c.GetInt64("user_id"), id); err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登陆
|
||||||
|
func (a *Api) PasskeyAuthBegin(c *gin.Context) {
|
||||||
|
|
||||||
|
cred, err := a.webAuthService.BeginLogin()
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Api) PasskeyAuthFinish(c *gin.Context) {
|
||||||
|
challenge := c.Query("challenge")
|
||||||
|
webAuthUser, err := a.webAuthService.FinishLogin(challenge, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
at, err := auth.GenerateTokenPair(webAuthUser.User, consts.SecretKey, consts.Day*time.Second, consts.Day*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
dto.Fail(c, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto.Success(c, dto.Auth{
|
||||||
|
Token: at.AccessToken,
|
||||||
|
ExpiresIn: time.Now().Add(consts.Day * time.Second).Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||