Compare commits
8 Commits
42c423bd32
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 474f592dcd | |||
| 59f5a87275 | |||
| 02caa45efc | |||
| f4f5ad6bd1 | |||
| 93aa31219d | |||
| a0a7640e8a | |||
| d566e1c57b | |||
| 8d60704eda |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -37,3 +37,7 @@ logs/
|
||||
# Test coverage
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
|
||||
# 开发文档
|
||||
document/
|
||||
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# GPT Team Manager
|
||||
|
||||
一个用于管理 ChatGPT Team 账号的全栈应用,支持多账号管理、卡密系统和团队邀请功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **Go** - 后端语言
|
||||
- **PostgreSQL** - 数据库
|
||||
- **JWT** - 身份认证
|
||||
|
||||
### 前端
|
||||
- **Vue 3** - 前端框架
|
||||
- **TypeScript** - 类型安全
|
||||
- **Vite** - 构建工具
|
||||
- **Pinia** - 状态管理
|
||||
- **shadcn-vue** - UI 组件库
|
||||
- **TailwindCSS** - 样式框架
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔐 **管理员认证** - JWT 登录
|
||||
- 👥 **账号管理** - 管理多个 ChatGPT Team 账号
|
||||
- 🔑 **卡密系统** - 创建、批量生成、导出卡密
|
||||
- 📨 **团队邀请** - 通过卡密邀请用户加入团队
|
||||
- 🌙 **暗色模式** - 自动切换主题
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Go 1.21+
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
- PostgreSQL 14+
|
||||
|
||||
### 后端配置
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 配置数据库连接
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/gpt_manager
|
||||
|
||||
# 运行
|
||||
go run ./cmd/main.go
|
||||
```
|
||||
|
||||
### 前端配置
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 开发模式运行
|
||||
pnpm run dev
|
||||
|
||||
# 生产构建
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
GPT_Management/
|
||||
├── backend/ # Go 后端
|
||||
│ ├── cmd/ # 入口文件
|
||||
│ ├── internal/
|
||||
│ │ ├── auth/ # JWT 认证
|
||||
│ │ ├── config/ # 配置
|
||||
│ │ ├── db/ # 数据库
|
||||
│ │ ├── handler/ # HTTP 处理器
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── repository/ # 数据仓库
|
||||
│ │ ├── router/ # 路由
|
||||
│ │ └── service/ # 业务服务
|
||||
│ └── go.mod
|
||||
├── frontend/ # Vue 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 接口
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── layouts/ # 布局
|
||||
│ │ ├── router/ # 路由
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ └── views/ # 页面
|
||||
│ └── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证
|
||||
- `POST /api/login` - 管理员登录
|
||||
- `GET /api/profile` - 获取当前用户信息
|
||||
|
||||
### 卡密管理
|
||||
- `GET /api/cardkeys` - 获取卡密列表
|
||||
- `POST /api/cardkeys` - 创建单个卡密
|
||||
- `POST /api/cardkeys/batch` - 批量创建卡密
|
||||
- `DELETE /api/cardkeys/delete` - 删除卡密
|
||||
- `DELETE /api/cardkeys/batch` - 批量删除卡密
|
||||
- `POST /api/cardkeys/toggle` - 切换卡密状态
|
||||
|
||||
### 账号管理
|
||||
- `GET /api/accounts` - 获取账号列表
|
||||
- `POST /api/accounts` - 添加账号
|
||||
- `DELETE /api/accounts/:id` - 删除账号
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -16,3 +16,6 @@ PORT=8080
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# API Token (用于外部 API 调用,可选)
|
||||
API_TOKEN=your-api-token-here
|
||||
37
backend/Dockerfile
Normal file
37
backend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download all dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy the source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main ./cmd/main.go
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/.env.example .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run
|
||||
CMD ["./main"]
|
||||
@@ -21,8 +21,6 @@ func Migrate(db *sql.DB) error {
|
||||
createCardKeysTable,
|
||||
createInvitationsTable,
|
||||
createAPIKeysTable,
|
||||
createSystemSettingsTable,
|
||||
insertDefaultSettings,
|
||||
}
|
||||
|
||||
for i, migration := range migrations {
|
||||
@@ -177,28 +175,3 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active);
|
||||
`
|
||||
|
||||
const createSystemSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(100) NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string',
|
||||
description VARCHAR(255),
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_system_settings_key ON system_settings(key);
|
||||
`
|
||||
|
||||
const insertDefaultSettings = `
|
||||
INSERT INTO system_settings (key, value, value_type, description) VALUES
|
||||
('turnstile_enabled', 'false', 'bool', 'Cloudflare Turnstile 开关'),
|
||||
('turnstile_site_key', '', 'string', 'Turnstile Site Key'),
|
||||
('turnstile_secret_key', '', 'string', 'Turnstile Secret Key'),
|
||||
('token_check_interval', '6', 'int', 'Token 检测间隔(小时)'),
|
||||
('token_failure_threshold', '2', 'int', '连续失败禁用阈值'),
|
||||
('invitation_validity_days', '30', 'int', '邀请有效期(天)'),
|
||||
('site_title', 'ChatGPT Team 邀请', 'string', '站点标题')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
`
|
||||
@@ -81,7 +81,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if admin == nil {
|
||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Invalid username or password",
|
||||
Message: "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if !admin.IsActive {
|
||||
respondJSON(w, http.StatusForbidden, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Account is disabled",
|
||||
Message: "账号已被禁用",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth.CheckPassword(req.Password, admin.PasswordHash) {
|
||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Invalid username or password",
|
||||
Message: "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,10 +39,13 @@ type BatchCreateCardKeyRequest struct {
|
||||
|
||||
// CardKeyResponse 卡密响应
|
||||
type CardKeyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *models.CardKey `json:"data,omitempty"`
|
||||
Keys []*models.CardKey `json:"keys,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *models.CardKey `json:"data,omitempty"`
|
||||
Keys []*models.CardKey `json:"keys,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// Handle 处理卡密接口 (GET: 列表, POST: 创建)
|
||||
@@ -187,8 +191,36 @@ func (h *CardKeyHandler) BatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// list 获取卡密列表
|
||||
func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取分页参数
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
cardKeys, err := h.repo.FindAll()
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||
|
||||
// 默认值
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := h.repo.Count()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to count card keys",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
cardKeys, err := h.repo.FindAllPaginated(page, pageSize)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
@@ -198,9 +230,12 @@ func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Keys: cardKeys,
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Keys: cardKeys,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,3 +246,122 @@ func generateCardKey() string {
|
||||
hex := strings.ToUpper(hex.EncodeToString(bytes))
|
||||
return hex[0:4] + "-" + hex[4:8] + "-" + hex[8:12] + "-" + hex[12:16]
|
||||
}
|
||||
|
||||
// Delete 删除单个卡密 (DELETE /api/cardkeys/delete?id=xxx)
|
||||
func (h *CardKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid card key ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete card key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card key deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
type BatchDeleteRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchDelete 批量删除卡密 (DELETE /api/cardkeys/batch)
|
||||
func (h *CardKeyHandler) BatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "No IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.BatchDelete(req.IDs); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete card keys",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card keys deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleActiveRequest 切换激活状态请求
|
||||
type ToggleActiveRequest struct {
|
||||
ID int `json:"id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ToggleActive 切换卡密激活状态 (POST /api/cardkeys/toggle)
|
||||
func (h *CardKeyHandler) ToggleActive(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req ToggleActiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.ToggleActive(req.ID, req.IsActive); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to toggle card key status",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card key status updated successfully",
|
||||
})
|
||||
}
|
||||
@@ -15,13 +15,15 @@ import (
|
||||
// ChatGPTAccountHandler ChatGPT 账号处理器
|
||||
type ChatGPTAccountHandler struct {
|
||||
repo *repository.ChatGPTAccountRepository
|
||||
invitationRepo *repository.InvitationRepository
|
||||
chatgptService *service.ChatGPTService
|
||||
}
|
||||
|
||||
// NewChatGPTAccountHandler 创建处理器
|
||||
func NewChatGPTAccountHandler(repo *repository.ChatGPTAccountRepository, chatgptService *service.ChatGPTService) *ChatGPTAccountHandler {
|
||||
func NewChatGPTAccountHandler(repo *repository.ChatGPTAccountRepository, invitationRepo *repository.InvitationRepository, chatgptService *service.ChatGPTService) *ChatGPTAccountHandler {
|
||||
return &ChatGPTAccountHandler{
|
||||
repo: repo,
|
||||
invitationRepo: invitationRepo,
|
||||
chatgptService: chatgptService,
|
||||
}
|
||||
}
|
||||
@@ -42,9 +44,12 @@ type AccountResponse struct {
|
||||
|
||||
// ListResponse 列表响应
|
||||
type ListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []*models.ChatGPTAccount `json:"data,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []*models.ChatGPTAccount `json:"data,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// Create 创建账号
|
||||
@@ -153,7 +158,36 @@ func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.repo.FindAll()
|
||||
// 获取分页参数
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||
|
||||
// 默认值
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := h.repo.Count()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||
Success: false,
|
||||
Message: "Failed to count accounts",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
accounts, err := h.repo.FindAllPaginated(page, pageSize)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||
Success: false,
|
||||
@@ -168,9 +202,12 @@ func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, ListResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: accounts,
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: accounts,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,10 +309,19 @@ func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 先删除相关的邀请记录,避免外键约束失败
|
||||
if err := h.invitationRepo.DeleteByAccountID(id); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete related invitations: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete account",
|
||||
Message: "Failed to delete account: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -285,3 +331,150 @@ func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
Message: "Account deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
type BatchDeleteAccountRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchDeleteResponse 批量操作响应
|
||||
type BatchOperationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
}
|
||||
|
||||
// BatchDelete 批量删除账号
|
||||
func (h *ChatGPTAccountHandler) BatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchDeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "No accounts selected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, id := range req.IDs {
|
||||
// 先删除相关的邀请记录
|
||||
if err := h.invitationRepo.DeleteByAccountID(id); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
// 再删除账号
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BatchOperationResponse{
|
||||
Success: failedCount == 0,
|
||||
Message: "Batch delete completed",
|
||||
SuccessCount: successCount,
|
||||
FailedCount: failedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRefreshRequest 批量刷新请求
|
||||
type BatchRefreshRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchRefresh 批量刷新账号
|
||||
func (h *ChatGPTAccountHandler) BatchRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchRefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "No accounts selected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, id := range req.IDs {
|
||||
account, err := h.repo.FindByID(id)
|
||||
if err != nil || account == nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 ChatGPT API 获取订阅信息
|
||||
subInfo, err := h.chatgptService.GetSubscription(account.TeamAccountID, account.AuthToken)
|
||||
if err != nil {
|
||||
account.ConsecutiveFailures++
|
||||
account.IsActive = false
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
h.repo.Update(account)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新账号信息
|
||||
if subInfo.IsValid {
|
||||
account.SeatsInUse = subInfo.SeatsInUse
|
||||
account.SeatsEntitled = subInfo.SeatsEntitled
|
||||
account.ActiveStart = sql.NullTime{Time: subInfo.ActiveStart, Valid: !subInfo.ActiveStart.IsZero()}
|
||||
account.ActiveUntil = sql.NullTime{Time: subInfo.ActiveUntil, Valid: !subInfo.ActiveUntil.IsZero()}
|
||||
account.IsActive = true
|
||||
account.ConsecutiveFailures = 0
|
||||
} else {
|
||||
account.IsActive = false
|
||||
account.ConsecutiveFailures++
|
||||
}
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
|
||||
if err := h.repo.Update(account); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BatchOperationResponse{
|
||||
Success: failedCount == 0,
|
||||
Message: "Batch refresh completed",
|
||||
SuccessCount: successCount,
|
||||
FailedCount: failedCount,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -54,6 +55,57 @@ type InviteResponse struct {
|
||||
AccountName string `json:"account_name,omitempty"`
|
||||
}
|
||||
|
||||
// InviteListResponse 邀请列表响应
|
||||
type InviteListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []*models.Invitation `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ListByAccount 获取账号的邀请列表 (GET /api/invite?account_id=xxx)
|
||||
func (h *InviteHandler) ListByAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
accountIDStr := r.URL.Query().Get("account_id")
|
||||
if accountIDStr == "" {
|
||||
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "account_id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var accountID int
|
||||
if _, err := fmt.Sscanf(accountIDStr, "%d", &accountID); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Invalid account_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invitations, err := h.invitationRepo.FindByAccountID(accountID)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Failed to fetch invitations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, InviteListResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: invitations,
|
||||
})
|
||||
}
|
||||
|
||||
// InviteByAdmin 管理员邀请/移除接口 (POST/DELETE /api/invite)
|
||||
func (h *InviteHandler) InviteByAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gpt-manager-go/internal/auth"
|
||||
@@ -31,7 +32,21 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// 解析 Token
|
||||
// 首先检查是否是 API Token
|
||||
apiToken := os.Getenv("API_TOKEN")
|
||||
if apiToken != "" && tokenString == apiToken {
|
||||
// API Token 认证成功,创建虚拟管理员上下文
|
||||
claims := &auth.Claims{
|
||||
UserID: 0,
|
||||
Username: "api_token",
|
||||
IsSuperAdmin: true,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JWT Token
|
||||
claims, err := auth.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"gpt-manager-go/internal/models"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// CardKeyRepository 卡密仓储
|
||||
@@ -87,3 +89,61 @@ func (r *CardKeyRepository) FindAll() ([]*models.CardKey, error) {
|
||||
}
|
||||
return cardKeys, nil
|
||||
}
|
||||
|
||||
// FindAllPaginated 分页获取卡密
|
||||
func (r *CardKeyRepository) FindAllPaginated(page, pageSize int) ([]*models.CardKey, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, key, max_uses, used_count, validity_type, expires_at, is_active, created_by_id, created_at
|
||||
FROM card_keys ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cardKeys []*models.CardKey
|
||||
for rows.Next() {
|
||||
ck := &models.CardKey{}
|
||||
if err := rows.Scan(
|
||||
&ck.ID, &ck.Key, &ck.MaxUses, &ck.UsedCount,
|
||||
&ck.ValidityType, &ck.ExpiresAt, &ck.IsActive,
|
||||
&ck.CreatedByID, &ck.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardKeys = append(cardKeys, ck)
|
||||
}
|
||||
return cardKeys, nil
|
||||
}
|
||||
|
||||
// Count 获取卡密总数
|
||||
func (r *CardKeyRepository) Count() (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(`SELECT COUNT(*) FROM card_keys`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Delete 删除单个卡密
|
||||
func (r *CardKeyRepository) Delete(id int) error {
|
||||
_, err := r.db.Exec(`DELETE FROM card_keys WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// BatchDelete 批量删除卡密
|
||||
func (r *CardKeyRepository) BatchDelete(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 构建 IN 子句
|
||||
query := `DELETE FROM card_keys WHERE id = ANY($1)`
|
||||
_, err := r.db.Exec(query, pq.Array(ids))
|
||||
return err
|
||||
}
|
||||
|
||||
// ToggleActive 切换卡密激活状态
|
||||
func (r *CardKeyRepository) ToggleActive(id int, isActive bool) error {
|
||||
_, err := r.db.Exec(`UPDATE card_keys SET is_active = $1 WHERE id = $2`, isActive, id)
|
||||
return err
|
||||
}
|
||||
@@ -205,3 +205,41 @@ func (r *ChatGPTAccountRepository) UpdateLastUsed(id int) error {
|
||||
`, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindAllPaginated 分页获取账号
|
||||
func (r *ChatGPTAccountRepository) FindAllPaginated(page, pageSize int) ([]*models.ChatGPTAccount, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, name, auth_token, team_account_id, seats_in_use, seats_entitled,
|
||||
active_start, active_until, is_active, consecutive_failures,
|
||||
last_check, last_used, created_at, updated_at
|
||||
FROM chatgpt_accounts ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var accounts []*models.ChatGPTAccount
|
||||
for rows.Next() {
|
||||
account := &models.ChatGPTAccount{}
|
||||
if err := rows.Scan(
|
||||
&account.ID, &account.Name, &account.AuthToken, &account.TeamAccountID,
|
||||
&account.SeatsInUse, &account.SeatsEntitled, &account.ActiveStart, &account.ActiveUntil,
|
||||
&account.IsActive, &account.ConsecutiveFailures, &account.LastCheck,
|
||||
&account.LastUsed, &account.CreatedAt, &account.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// Count 获取账号总数
|
||||
func (r *ChatGPTAccountRepository) Count() (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(`SELECT COUNT(*) FROM chatgpt_accounts`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
@@ -81,3 +81,35 @@ func (r *InvitationRepository) DeleteByEmailAndAccountID(email string, accountID
|
||||
`, email, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByAccountID 根据账号ID删除所有邀请记录
|
||||
func (r *InvitationRepository) DeleteByAccountID(accountID int) error {
|
||||
_, err := r.db.Exec(`DELETE FROM invitations WHERE account_id = $1`, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindByAccountID 根据账号ID查找邀请记录
|
||||
func (r *InvitationRepository) FindByAccountID(accountID int) ([]*models.Invitation, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, card_key_id, account_id, invited_email, status, error_message, expires_at, created_at, updated_at
|
||||
FROM invitations WHERE account_id = $1 ORDER BY created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invitations []*models.Invitation
|
||||
for rows.Next() {
|
||||
inv := &models.Invitation{}
|
||||
if err := rows.Scan(
|
||||
&inv.ID, &inv.CardKeyID, &inv.AccountID, &inv.InvitedEmail,
|
||||
&inv.Status, &inv.ErrorMessage, &inv.ExpiresAt,
|
||||
&inv.CreatedAt, &inv.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, inv)
|
||||
}
|
||||
return invitations, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gpt-manager-go/internal/middleware"
|
||||
"gpt-manager-go/internal/repository"
|
||||
"gpt-manager-go/internal/service"
|
||||
"gpt-manager-go/internal/static"
|
||||
)
|
||||
|
||||
// SetupRoutes 设置路由
|
||||
@@ -25,7 +26,7 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
||||
|
||||
// 初始化处理器
|
||||
authHandler := handler.NewAuthHandler(adminRepo)
|
||||
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, chatgptService)
|
||||
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, invitationRepo, chatgptService)
|
||||
inviteHandler := handler.NewInviteHandler(chatgptAccountRepo, invitationRepo, cardKeyRepo, chatgptService)
|
||||
cardKeyHandler := handler.NewCardKeyHandler(cardKeyRepo)
|
||||
|
||||
@@ -46,13 +47,39 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
||||
protectedMux.HandleFunc("/api/accounts/create", accountHandler.Create)
|
||||
protectedMux.HandleFunc("/api/accounts/refresh", accountHandler.Refresh)
|
||||
protectedMux.HandleFunc("/api/accounts/delete", accountHandler.Delete)
|
||||
protectedMux.HandleFunc("/api/accounts/batch/delete", accountHandler.BatchDelete)
|
||||
protectedMux.HandleFunc("/api/accounts/batch/refresh", accountHandler.BatchRefresh)
|
||||
|
||||
// 邀请接口 (管理员) - POST: 邀请, DELETE: 移除
|
||||
protectedMux.HandleFunc("/api/invite", inviteHandler.InviteByAdmin)
|
||||
// 邀请接口 (管理员) - GET: 列表, POST: 邀请, DELETE: 移除
|
||||
protectedMux.HandleFunc("/api/invite", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
inviteHandler.ListByAccount(w, r)
|
||||
case http.MethodPost, http.MethodDelete:
|
||||
inviteHandler.InviteByAdmin(w, r)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte(`{"success":false,"message":"Method not allowed"}`))
|
||||
}
|
||||
})
|
||||
|
||||
// 卡密管理接口
|
||||
protectedMux.HandleFunc("/api/cardkeys", cardKeyHandler.Handle) // GET: 列表, POST: 创建
|
||||
protectedMux.HandleFunc("/api/cardkeys/batch", cardKeyHandler.BatchCreate) // POST: 批量创建
|
||||
protectedMux.HandleFunc("/api/cardkeys", cardKeyHandler.Handle) // GET: 列表, POST: 创建
|
||||
protectedMux.HandleFunc("/api/cardkeys/batch", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
cardKeyHandler.BatchCreate(w, r)
|
||||
case http.MethodDelete:
|
||||
cardKeyHandler.BatchDelete(w, r)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte(`{"success":false,"message":"Method not allowed"}`))
|
||||
}
|
||||
})
|
||||
protectedMux.HandleFunc("/api/cardkeys/delete", cardKeyHandler.Delete) // DELETE: 单个删除
|
||||
protectedMux.HandleFunc("/api/cardkeys/toggle", cardKeyHandler.ToggleActive) // POST: 切换激活状态
|
||||
|
||||
// 挂载受保护的路由
|
||||
mux.Handle("/api/profile", middleware.AuthMiddleware(protectedMux))
|
||||
@@ -62,6 +89,9 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
||||
mux.Handle("/api/cardkeys", middleware.AuthMiddleware(protectedMux))
|
||||
mux.Handle("/api/cardkeys/", middleware.AuthMiddleware(protectedMux))
|
||||
|
||||
// 静态文件服务(前端)
|
||||
mux.Handle("/", static.Handler())
|
||||
|
||||
// CORS 中间件包装
|
||||
return corsMiddleware(mux)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as t,h as o,n as r,u as n,v as c,x as l,o as p}from"./index-B0FmaMuw.js";const i=t({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(a,d)=>(p(),o("p",{"data-slot":"card-description",class:r(n(c)("text-muted-foreground text-sm",e.class))},[l(a.$slots,"default")],2))}});export{i as _};
|
||||
2
backend/internal/static/dist/assets/CardKeysPage-3J4JuRjk.js
vendored
Normal file
2
backend/internal/static/dist/assets/CardKeysPage-3J4JuRjk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{d as r,h as e,x as o,n as c,u as n,v as d,o as l}from"./index-B0FmaMuw.js";const _=r({__name:"Card",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card",class:c(n(d)("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",a.class))},[o(t.$slots,"default")],2))}}),i=r({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-content",class:c(n(d)("px-6",a.class))},[o(t.$slots,"default")],2))}}),m=r({__name:"CardHeader",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-header",class:c(n(d)("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",a.class))},[o(t.$slots,"default")],2))}}),f=r({__name:"CardTitle",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("h3",{"data-slot":"card-title",class:c(n(d)("leading-none font-semibold",a.class))},[o(t.$slots,"default")],2))}});export{_,m as a,f as b,i as c};
|
||||
1
backend/internal/static/dist/assets/Checkbox.vue_vue_type_script_setup_true_lang-BEbtniid.js
vendored
Normal file
1
backend/internal/static/dist/assets/Checkbox.vue_vue_type_script_setup_true_lang-BEbtniid.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/DashboardPage-C_e1hozu.js
vendored
Normal file
1
backend/internal/static/dist/assets/DashboardPage-C_e1hozu.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as b,d as w,p as k,j as g,h as i,f as d,a as r,i as v,b as t,w as s,u as e,_ as h,e as n,n as C,U as j,t as c,o}from"./index-B0FmaMuw.js";import{_ as f,a as m,b as _,c as u}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as p}from"./Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js";import{u as M}from"./accounts-CLfPgj8J.js";import{R as V}from"./refresh-cw-Bst35UPe.js";import{C as $}from"./circle-x-C8-4gjQR.js";const z=b("armchair",[["path",{d:"M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3",key:"irtipd"}],["path",{d:"M3 16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",key:"1qyhux"}],["path",{d:"M5 18v2",key:"ppbyun"}],["path",{d:"M19 18v2",key:"gy7782"}]]);const A=b("circle-check-big",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]]),B={class:"space-y-6"},T={class:"flex items-center justify-between"},D={class:"grid gap-4 md:grid-cols-2 lg:grid-cols-4"},N={key:1,class:"text-2xl font-bold"},S={key:1,class:"text-2xl font-bold text-green-600"},L={key:1,class:"text-2xl font-bold text-red-600"},R={key:1,class:"text-2xl font-bold"},U={class:"flex items-center justify-between"},q={class:"text-destructive"},J=w({__name:"DashboardPage",setup(E){const l=M();k(()=>{x()});async function x(){try{await l.fetchAccounts()}catch(y){g.error(y.message||"加载数据失败")}}return(y,a)=>(o(),i("div",B,[d("div",T,[a[1]||(a[1]=d("h1",{class:"text-2xl font-bold"},"Dashboard",-1)),t(e(h),{variant:"outline",size:"sm",onClick:x,disabled:e(l).loading},{default:s(()=>[t(e(V),{class:C(["h-4 w-4 mr-2",e(l).loading&&"animate-spin"])},null,8,["class"]),a[0]||(a[0]=n(" 刷新 ",-1))]),_:1},8,["disabled"])]),d("div",D,[t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[2]||(a[2]=[n("Team 总数",-1)])]),_:1}),t(e(j),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",N,c(e(l).totalTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[3]||(a[3]=[n("有效订阅",-1)])]),_:1}),t(e(A),{class:"h-4 w-4 text-green-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",S,c(e(l).validTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[4]||(a[4]=[n("无效订阅",-1)])]),_:1}),t(e($),{class:"h-4 w-4 text-red-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",L,c(e(l).invalidTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[5]||(a[5]=[n("剩余席位",-1)])]),_:1}),t(e(z),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",R,c(e(l).totalAvailableSeats),1))]),_:1})]),_:1})]),e(l).error?(o(),r(e(f),{key:0,class:"border-destructive"},{default:s(()=>[t(e(u),{class:"pt-6"},{default:s(()=>[d("div",U,[d("p",q,c(e(l).error),1),t(e(h),{variant:"outline",size:"sm",onClick:x},{default:s(()=>[...a[6]||(a[6]=[n(" 重试 ",-1)])]),_:1})])]),_:1})]),_:1})):v("",!0)]))}});export{J as default};
|
||||
1
backend/internal/static/dist/assets/JoinPage-mJKO0v_Y.js
vendored
Normal file
1
backend/internal/static/dist/assets/JoinPage-mJKO0v_Y.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as C,d as w,r as c,a as f,u as a,w as t,o as d,b as l,e as n,f as p,g as V,h as $,i as _,n as h,t as y,_ as B,j as i}from"./index-B0FmaMuw.js";import{_ as N,a as T,b as K,c as L}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as S}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_ as g,a as k}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{i as z}from"./invite-DvsN2S4N.js";import{C as P}from"./circle-x-C8-4gjQR.js";import{L as U}from"./index-DwEwynZa.js";const j=C("circle-check",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]),D={class:"space-y-2"},E={class:"space-y-2"},F=w({__name:"JoinPage",setup(G){const u=c(""),o=c(""),r=c(!1),s=c(null),x=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;async function b(){if(!u.value.trim()){i.error("请输入邮箱");return}if(!x.test(u.value)){i.error("邮箱格式不正确");return}if(!o.value.trim()){i.error("请输入卡密");return}r.value=!0,s.value=null;try{const m=await z({email:u.value.trim(),card_key:o.value.trim()});m.data.success?(s.value={success:!0,message:"提交成功,已发起邀请!"},i.success("提交成功"),u.value="",o.value=""):(s.value={success:!1,message:m.data.message||"提交失败,请检查卡密或邮箱"},i.error(s.value.message))}catch(m){const e=m.response?.data?.message||"提交失败,请检查卡密或邮箱";s.value={success:!1,message:e},i.error(e)}finally{r.value=!1}}return(m,e)=>(d(),f(a(N),{class:"w-full max-w-md mx-4"},{default:t(()=>[l(a(T),{class:"text-center"},{default:t(()=>[l(a(K),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[n("加入 Team",-1)])]),_:1}),l(a(S),null,{default:t(()=>[...e[3]||(e[3]=[n("输入邮箱和卡密,即可加入 ChatGPT Team",-1)])]),_:1})]),_:1}),l(a(L),null,{default:t(()=>[p("form",{onSubmit:V(b,["prevent"]),class:"space-y-4"},[p("div",D,[l(a(g),{for:"email"},{default:t(()=>[...e[4]||(e[4]=[n("邮箱",-1)])]),_:1}),l(a(k),{id:"email",modelValue:u.value,"onUpdate:modelValue":e[0]||(e[0]=v=>u.value=v),type:"email",placeholder:"your@email.com",disabled:r.value},null,8,["modelValue","disabled"])]),p("div",E,[l(a(g),{for:"cardKey"},{default:t(()=>[...e[5]||(e[5]=[n("卡密",-1)])]),_:1}),l(a(k),{id:"cardKey",modelValue:o.value,"onUpdate:modelValue":e[1]||(e[1]=v=>o.value=v),type:"text",placeholder:"请输入卡密",disabled:r.value},null,8,["modelValue","disabled"])]),s.value?(d(),$("div",{key:0,class:h(["flex items-center gap-2 p-3 rounded-lg text-sm",s.value.success?"bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300":"bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300"])},[s.value.success?(d(),f(a(j),{key:0,class:"h-4 w-4 shrink-0"})):(d(),f(a(P),{key:1,class:"h-4 w-4 shrink-0"})),p("span",null,y(s.value.message),1)],2)):_("",!0),l(a(B),{type:"submit",class:"w-full",disabled:r.value},{default:t(()=>[r.value?(d(),f(a(U),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):_("",!0),n(" "+y(r.value?"提交中...":"提交"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{F as default};
|
||||
1
backend/internal/static/dist/assets/Label.vue_vue_type_script_setup_true_lang-duvmWwej.js
vendored
Normal file
1
backend/internal/static/dist/assets/Label.vue_vue_type_script_setup_true_lang-duvmWwej.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as m,a as V,o as v,w as $,x as B,B as C,u as d,P as O,L as P,r as q,I as h,q as E,H as M,M as D,N as F,h as I,n as J,v as S,O as k}from"./index-B0FmaMuw.js";import{a as T,i as U,r as z}from"./index-DwEwynZa.js";var H=m({__name:"Label",props:{for:{type:String,required:!1},asChild:{type:Boolean,required:!1},as:{type:null,required:!1,default:"label"}},setup(a){const s=a;return T(),(i,n)=>(v(),V(d(O),C(s,{onMousedown:n[0]||(n[0]=l=>{!l.defaultPrevented&&l.detail>1&&l.preventDefault()})}),{default:$(()=>[B(i.$slots,"default")]),_:3},16))}}),R=H;function j(a){return JSON.parse(JSON.stringify(a))}function A(a,s,i,n={}){var l,r;const{clone:o=!1,passive:c=!1,eventName:L,deep:b=!1,defaultValue:N,shouldEmit:g}=n,e=P(),_=i||e?.emit||(e==null||(l=e.$emit)===null||l===void 0?void 0:l.bind(e))||(e==null||(r=e.proxy)===null||r===void 0||(r=r.$emit)===null||r===void 0?void 0:r.bind(e?.proxy));let u=L;u=u||`update:${s.toString()}`;const x=t=>o?typeof o=="function"?o(t):j(t):t,y=()=>U(a[s])?x(a[s]):N,w=t=>{g?g(t)&&_(u,t):_(u,t)};if(c){const t=q(y());let p=!1;return h(()=>a[s],f=>{p||(p=!0,t.value=x(f),M(()=>p=!1))}),h(t,f=>{!p&&(f!==a[s]||b)&&w(f)},{deep:b}),t}else return E({get(){return y()},set(t){w(t)}})}const Q=m({__name:"Input",props:{defaultValue:{},modelValue:{},class:{}},emits:["update:modelValue"],setup(a,{emit:s}){const i=a,l=A(i,"modelValue",s,{passive:!0,defaultValue:i.defaultValue});return(r,o)=>D((v(),I("input",{"onUpdate:modelValue":o[0]||(o[0]=c=>k(l)?l.value=c:null),"data-slot":"input",class:J(d(S)("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm","focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]","aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",i.class))},null,2)),[[F,d(l)]])}}),W=m({__name:"Label",props:{for:{},asChild:{type:Boolean},as:{},class:{}},setup(a){const s=a,i=z(s,"class");return(n,l)=>(v(),V(d(R),C({"data-slot":"label"},d(i),{class:d(S)("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",s.class)}),{default:$(()=>[B(n.$slots,"default")]),_:3},16,["class"]))}});export{W as _,Q as a};
|
||||
1
backend/internal/static/dist/assets/LoginPage-j_vZojoK.js
vendored
Normal file
1
backend/internal/static/dist/assets/LoginPage-j_vZojoK.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as V,k as g,r as i,a as c,w as t,u as a,l as $,m as k,o as p,b as s,e as o,f as m,g as C,_ as L,i as N,t as S,j as f}from"./index-B0FmaMuw.js";import{_ as B,a as R,b as U,c as h}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as j}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_,a as v}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{L as q}from"./index-DwEwynZa.js";const A={class:"space-y-2"},D={class:"space-y-2"},G=V({__name:"LoginPage",setup(M){const w=$(),b=k(),x=g(),r=i(""),u=i(""),l=i(!1);async function y(){if(!r.value.trim()||!u.value.trim()){f.error("请输入账号和密码");return}l.value=!0;const d=await x.login({username:r.value.trim(),password:u.value});if(l.value=!1,d.success){f.success("登录成功");const e=b.query.redirect;w.push(e||"/admin/dashboard")}else f.error(d.message||"登录失败")}return(d,e)=>(p(),c(a(B),{class:"w-full max-w-md mx-4"},{default:t(()=>[s(a(R),{class:"text-center"},{default:t(()=>[s(a(U),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[o("管理后台登录",-1)])]),_:1}),s(a(j),null,{default:t(()=>[...e[3]||(e[3]=[o("请输入您的账号和密码",-1)])]),_:1})]),_:1}),s(a(h),null,{default:t(()=>[m("form",{onSubmit:C(y,["prevent"]),class:"space-y-4"},[m("div",A,[s(a(_),{for:"username"},{default:t(()=>[...e[4]||(e[4]=[o("账号",-1)])]),_:1}),s(a(v),{id:"username",modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=n=>r.value=n),type:"text",placeholder:"请输入账号",disabled:l.value,autocomplete:"username"},null,8,["modelValue","disabled"])]),m("div",D,[s(a(_),{for:"password"},{default:t(()=>[...e[5]||(e[5]=[o("密码",-1)])]),_:1}),s(a(v),{id:"password",modelValue:u.value,"onUpdate:modelValue":e[1]||(e[1]=n=>u.value=n),type:"password",placeholder:"请输入密码",disabled:l.value,autocomplete:"current-password"},null,8,["modelValue","disabled"])]),s(a(L),{type:"submit",class:"w-full",disabled:l.value},{default:t(()=>[l.value?(p(),c(a(q),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):N("",!0),o(" "+S(l.value?"登录中...":"登录"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{G as default};
|
||||
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js
vendored
Normal file
1
backend/internal/static/dist/assets/Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as a,h as n,n as o,u as t,v as r,o as l}from"./index-B0FmaMuw.js";const u=a({__name:"Skeleton",props:{class:{}},setup(s){const e=s;return(c,p)=>(l(),n("div",{"data-slot":"skeleton",class:o(t(r)("animate-pulse rounded-md bg-primary/10",e.class))},null,2))}});export{u as _};
|
||||
1
backend/internal/static/dist/assets/TeamInvitesPage-DMa0gCmF.js
vendored
Normal file
1
backend/internal/static/dist/assets/TeamInvitesPage-DMa0gCmF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/TeamsPage-3k-Ffe4V.js
vendored
Normal file
1
backend/internal/static/dist/assets/TeamsPage-3k-Ffe4V.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/accounts-CLfPgj8J.js
vendored
Normal file
1
backend/internal/static/dist/assets/accounts-CLfPgj8J.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{y as s,z as p,r as o,q as r}from"./index-B0FmaMuw.js";function m(e){const a=new URLSearchParams().toString();return s.get(`/api/accounts${a?`?${a}`:""}`)}function A(e){return s.post("/api/accounts/create",e)}function b(e){return s.post(`/api/accounts/refresh?id=${e}`)}function w(e){return s.delete(`/api/accounts/delete?id=${e}`)}function y(e){return s.delete("/api/accounts/batch/delete",{data:{ids:e}})}function S(e){return s.post("/api/accounts/batch/refresh",{ids:e})}const _=p("accounts",()=>{const e=o([]),n=o(!1),a=o(null),u=r(()=>e.value.length),i=r(()=>e.value.filter(t=>t.is_active).length),l=r(()=>e.value.filter(t=>!t.is_active).length),d=r(()=>e.value.reduce((t,c)=>t+Math.max(0,(c.seats_entitled||0)-(c.seats_in_use||0)),0));async function f(){n.value=!0,a.value=null;try{const t=await m();if(t.data.success)e.value=t.data.data||[];else throw new Error(t.data.message||"获取账号列表失败")}catch(t){throw a.value=t.message||"获取账号列表失败",t}finally{n.value=!1}}function h(t){const c=e.value.findIndex(v=>v.id===t.id);c!==-1&&(e.value[c]=t)}return{accounts:e,loading:n,error:a,totalTeams:u,validTeams:i,invalidTeams:l,totalAvailableSeats:d,fetchAccounts:f,updateAccount:h}});export{S as a,y as b,A as c,w as d,b as r,_ as u};
|
||||
1
backend/internal/static/dist/assets/circle-x-C8-4gjQR.js
vendored
Normal file
1
backend/internal/static/dist/assets/circle-x-C8-4gjQR.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c}from"./index-B0FmaMuw.js";const r=c("circle-x",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m15 9-6 6",key:"1uzhvr"}],["path",{d:"m9 9 6 6",key:"z0biqf"}]]);export{r as C};
|
||||
7
backend/internal/static/dist/assets/index-B0FmaMuw.js
vendored
Normal file
7
backend/internal/static/dist/assets/index-B0FmaMuw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/index-C_xOPDav.css
vendored
Normal file
1
backend/internal/static/dist/assets/index-C_xOPDav.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/index-DwEwynZa.js
vendored
Normal file
1
backend/internal/static/dist/assets/index-DwEwynZa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/invite-DvsN2S4N.js
vendored
Normal file
1
backend/internal/static/dist/assets/invite-DvsN2S4N.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{y as t}from"./index-B0FmaMuw.js";function e(i){return t.post("/api/invite/card",i)}function a(i){return t.get(`/api/invite?account_id=${i}`)}function r(i){return t.delete("/api/invite",{data:i})}function o(i){return t.post("/api/invite",i)}export{o as a,r as d,e as i,a as l};
|
||||
1
backend/internal/static/dist/assets/refresh-cw-Bst35UPe.js
vendored
Normal file
1
backend/internal/static/dist/assets/refresh-cw-Bst35UPe.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as e}from"./index-B0FmaMuw.js";const t=e("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);export{t as R};
|
||||
14
backend/internal/static/dist/index.html
vendored
Normal file
14
backend/internal/static/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mygo Team</title>
|
||||
<script type="module" crossorigin src="/assets/index-B0FmaMuw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_xOPDav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/internal/static/dist/vite.svg
vendored
Normal file
1
backend/internal/static/dist/vite.svg
vendored
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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
56
backend/internal/static/static.go
Normal file
56
backend/internal/static/static.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var StaticFiles embed.FS
|
||||
|
||||
// Handler 返回静态文件处理器
|
||||
// 用于服务嵌入的前端静态文件
|
||||
func Handler() http.Handler {
|
||||
// 提取 dist 子目录
|
||||
distFS, err := fs.Sub(StaticFiles, "dist")
|
||||
if err != nil {
|
||||
// 如果 dist 目录不存在,返回空处理器
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Static files not available", http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 对于 API 路径,不处理
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试服务静态文件
|
||||
// 对于 SPA,如果文件不存在,返回 index.html
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
_, err := fs.Stat(distFS, strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
// 文件不存在,返回 index.html(支持 SPA 路由)
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// IsAvailable 检查静态文件是否可用
|
||||
func IsAvailable() bool {
|
||||
_, err := fs.Sub(StaticFiles, "dist")
|
||||
return err == nil
|
||||
}
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
app:
|
||||
container_name: gpt-manager
|
||||
build: ./backend
|
||||
ports:
|
||||
- "${EXPOSE_PORT}:8080"
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PORT=${PORT}
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
container_name: gpt-manager-db
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_URL=http://localhost:8080
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mygo Team</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "4",
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
1891
frontend/pnpm-lock.yaml
generated
Normal file
1891
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||
import { Toaster } from 'vue-sonner'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const layout = computed(() => {
|
||||
if (route.meta.layout === 'admin') {
|
||||
return AdminLayout
|
||||
}
|
||||
return PublicLayout
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="layout">
|
||||
<router-view />
|
||||
</component>
|
||||
<Toaster position="top-center" rich-colors />
|
||||
</template>
|
||||
74
frontend/src/api/accounts.ts
Normal file
74
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import request from './request'
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
team_account_id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
seats_in_use: number
|
||||
seats_entitled: number
|
||||
active_until?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AccountsResponse {
|
||||
success: boolean
|
||||
data?: Account[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AccountResponse {
|
||||
success: boolean
|
||||
data?: Account
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateAccountRequest {
|
||||
team_account_id: string
|
||||
auth_token: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getAccounts(params?: PaginationParams) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set('page', String(params.page))
|
||||
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||
const query = searchParams.toString()
|
||||
return request.get<AccountsResponse>(`/api/accounts${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function createAccount(data: CreateAccountRequest) {
|
||||
return request.post<AccountResponse>('/api/accounts/create', data)
|
||||
}
|
||||
|
||||
export function refreshAccount(id: number) {
|
||||
return request.post<AccountResponse>(`/api/accounts/refresh?id=${id}`)
|
||||
}
|
||||
|
||||
export function deleteAccount(id: number) {
|
||||
return request.delete(`/api/accounts/delete?id=${id}`)
|
||||
}
|
||||
|
||||
export interface BatchOperationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
success_count?: number
|
||||
failed_count?: number
|
||||
}
|
||||
|
||||
export function batchDeleteAccounts(ids: number[]) {
|
||||
return request.delete<BatchOperationResponse>('/api/accounts/batch/delete', { data: { ids } })
|
||||
}
|
||||
|
||||
export function batchRefreshAccounts(ids: number[]) {
|
||||
return request.post<BatchOperationResponse>('/api/accounts/batch/refresh', { ids })
|
||||
}
|
||||
29
frontend/src/api/auth.ts
Normal file
29
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import request from './request'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
token?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
success: boolean
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function login(data: LoginRequest) {
|
||||
return request.post<LoginResponse>('/api/login', data)
|
||||
}
|
||||
|
||||
export function getProfile() {
|
||||
return request.get<ProfileResponse>('/api/profile')
|
||||
}
|
||||
73
frontend/src/api/cardkeys.ts
Normal file
73
frontend/src/api/cardkeys.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from './request'
|
||||
|
||||
export interface CardKey {
|
||||
id: number
|
||||
key: string
|
||||
max_uses: number
|
||||
used_count: number
|
||||
validity_type: string
|
||||
expires_at: string
|
||||
is_active: boolean
|
||||
created_by_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CardKeysResponse {
|
||||
success: boolean
|
||||
keys?: CardKey[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CardKeyResponse {
|
||||
success: boolean
|
||||
data?: CardKey
|
||||
keys?: CardKey[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateCardKeyRequest {
|
||||
validity_days?: number
|
||||
max_uses?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateCardKeyRequest {
|
||||
count: number
|
||||
validity_days?: number
|
||||
max_uses?: number
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getCardKeys(params?: PaginationParams) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set('page', String(params.page))
|
||||
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||
const query = searchParams.toString()
|
||||
return request.get<CardKeysResponse>(`/api/cardkeys${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function createCardKey(data: CreateCardKeyRequest) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys', data)
|
||||
}
|
||||
|
||||
export function batchCreateCardKeys(data: BatchCreateCardKeyRequest) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys/batch', data)
|
||||
}
|
||||
|
||||
export function deleteCardKey(id: number) {
|
||||
return request.delete<CardKeyResponse>(`/api/cardkeys/delete?id=${id}`)
|
||||
}
|
||||
|
||||
export function batchDeleteCardKeys(ids: number[]) {
|
||||
return request.delete<CardKeyResponse>('/api/cardkeys/batch', { data: { ids } })
|
||||
}
|
||||
|
||||
export function toggleCardKeyActive(id: number, is_active: boolean) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys/toggle', { id, is_active })
|
||||
}
|
||||
56
frontend/src/api/invite.ts
Normal file
56
frontend/src/api/invite.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import request from './request'
|
||||
|
||||
export interface InviteByCardRequest {
|
||||
email: string
|
||||
card_key: string
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: number
|
||||
invited_email: string
|
||||
account_id: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface InvitationsResponse {
|
||||
success: boolean
|
||||
data?: Invitation[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DeleteInviteRequest {
|
||||
email: string
|
||||
account_id: number
|
||||
}
|
||||
|
||||
// Public endpoint - no auth required
|
||||
export function inviteByCard(data: InviteByCardRequest) {
|
||||
return request.post('/api/invite/card', data)
|
||||
}
|
||||
|
||||
// Admin endpoints - auth required
|
||||
export function listInvitations(accountId: number) {
|
||||
return request.get<InvitationsResponse>(`/api/invite?account_id=${accountId}`)
|
||||
}
|
||||
|
||||
export function deleteInvite(data: DeleteInviteRequest) {
|
||||
return request.delete('/api/invite', { data })
|
||||
}
|
||||
|
||||
export interface AdminInviteRequest {
|
||||
email: string
|
||||
account_id: number
|
||||
}
|
||||
|
||||
export interface AdminInviteResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
invitation_id?: number
|
||||
account_name?: string
|
||||
}
|
||||
|
||||
export function inviteByAdmin(data: AdminInviteRequest) {
|
||||
return request.post<AdminInviteResponse>('/api/invite', data)
|
||||
}
|
||||
|
||||
42
frontend/src/api/request.ts
Normal file
42
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_URL || '',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add Authorization header
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401/403
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
1
frontend/src/assets/vue.svg
Normal file
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 |
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
21
frontend/src/components/ui/alert/Alert.vue
Normal file
21
frontend/src/components/ui/alert/Alert.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert"
|
||||
:class="cn(alertVariants({ variant }), props.class)"
|
||||
role="alert"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
frontend/src/components/ui/alert/index.ts
Normal file
24
frontend/src/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
26
frontend/src/components/ui/badge/Badge.vue
Normal file
26
frontend/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
frontend/src/components/ui/badge/index.ts
Normal file
26
frontend/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
29
frontend/src/components/ui/button/Button.vue
Normal file
29
frontend/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
38
frontend/src/components/ui/button/index.ts
Normal file
38
frontend/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
22
frontend/src/components/ui/card/Card.vue
Normal file
22
frontend/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardAction.vue
Normal file
17
frontend/src/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardContent.vue
Normal file
17
frontend/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
frontend/src/components/ui/card/index.ts
Normal file
7
frontend/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
frontend/src/components/ui/checkbox/index.ts
Normal file
1
frontend/src/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user