Compare commits

...

8 Commits

Author SHA1 Message Date
sar
474f592dcd feat(teams): fix checkbox multi-select and improve batch operations UI
- Fix checkbox binding using :model-value instead of :checked
- Change selectedIds from Set to reactive array for proper Vue reactivity
- Move batch refresh/delete buttons to top bar (matching CardKeysPage layout)
- Buttons show selection count like 'Refresh (2)' when items selected
- Swap position of 'Add Team' and 'Random Invite' buttons
- Remove unused isIndeterminate computed property
2026-01-16 11:53:04 +08:00
sar
59f5a87275 feat: 使用docker部署 2026-01-14 15:37:35 +08:00
sar
02caa45efc feat: 更新标签页名称为 mygo Team,修复邀请列表空数据时的错误提示 2026-01-14 13:57:37 +08:00
sar
f4f5ad6bd1 feat: 将前端 dist 嵌入 Go 后端实现单文件部署 2026-01-14 13:33:15 +08:00
sar
93aa31219d feat: 添加功能和修复问题
- 添加全局 API Token 认证支持 (环境变量 API_TOKEN)
- Team 页面添加直接邀请按钮
- Team 页面添加随机邀请按钮
- 修复已邀请用户列表字段名不匹配问题
- 修复数据库为空时错误显示 toast 的问题
2026-01-14 13:25:49 +08:00
sar
a0a7640e8a fix: 修复已邀请用户列表无法显示的问题 2026-01-14 10:34:18 +08:00
sar
d566e1c57b docs: 添加项目 README 2026-01-13 21:37:18 +08:00
sar
8d60704eda feat: 实现前端卡密管理界面
- 卡密列表展示与分页功能

- 单个/批量创建卡密

- 卡密删除与批量删除

- 卡密导出功能 (file-saver)

- 启用/禁用状态切换

- 状态判断 (有效/已使用/已失效)

- Toast 通知系统 (vue-sonner)

- 登录页面错误提示优化

- 后端登录错误消息中文化
2026-01-13 21:34:56 +08:00
168 changed files with 7464 additions and 95 deletions

4
.gitignore vendored
View File

@@ -37,3 +37,7 @@ logs/
# Test coverage
coverage.out
coverage.html
# 开发文档
document/

119
README.md Normal file
View 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

View File

@@ -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
View 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"]

View File

@@ -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;
`

View File

@@ -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
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@@ -42,6 +43,9 @@ type CardKeyResponse struct {
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,
@@ -201,6 +233,9 @@ func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
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",
})
}

View File

@@ -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,
}
}
@@ -45,6 +47,9 @@ type ListResponse struct {
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,
@@ -171,6 +205,9 @@ func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
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,
})
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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/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)
}

View File

@@ -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 _};

File diff suppressed because one or more lines are too long

View File

@@ -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};

File diff suppressed because one or more lines are too long

View 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};

View 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};

View 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};

View 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

View 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 _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View 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
View 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
View 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

View 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
View 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
View File

@@ -0,0 +1 @@
VITE_BASE_URL=http://localhost:8080

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
frontend/public/vite.svg Normal file
View 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
View 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>

View 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
View 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')
}

View 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 })
}

View 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)
}

View 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

View 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

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
</template>

View 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-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View 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>

View File

@@ -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>

View 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"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"

View 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>

View 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>

View 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>

View 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