Compare commits
6 Commits
d566e1c57b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 474f592dcd | |||
| 59f5a87275 | |||
| 02caa45efc | |||
| f4f5ad6bd1 | |||
| 93aa31219d | |||
| a0a7640e8a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,4 +41,3 @@ coverage.html
|
|||||||
|
|
||||||
# 开发文档
|
# 开发文档
|
||||||
document/
|
document/
|
||||||
database_schema.md
|
|
||||||
@@ -16,3 +16,6 @@ PORT=8080
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=admin123
|
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"]
|
||||||
@@ -15,13 +15,15 @@ import (
|
|||||||
// ChatGPTAccountHandler ChatGPT 账号处理器
|
// ChatGPTAccountHandler ChatGPT 账号处理器
|
||||||
type ChatGPTAccountHandler struct {
|
type ChatGPTAccountHandler struct {
|
||||||
repo *repository.ChatGPTAccountRepository
|
repo *repository.ChatGPTAccountRepository
|
||||||
|
invitationRepo *repository.InvitationRepository
|
||||||
chatgptService *service.ChatGPTService
|
chatgptService *service.ChatGPTService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatGPTAccountHandler 创建处理器
|
// NewChatGPTAccountHandler 创建处理器
|
||||||
func NewChatGPTAccountHandler(repo *repository.ChatGPTAccountRepository, chatgptService *service.ChatGPTService) *ChatGPTAccountHandler {
|
func NewChatGPTAccountHandler(repo *repository.ChatGPTAccountRepository, invitationRepo *repository.InvitationRepository, chatgptService *service.ChatGPTService) *ChatGPTAccountHandler {
|
||||||
return &ChatGPTAccountHandler{
|
return &ChatGPTAccountHandler{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
|
invitationRepo: invitationRepo,
|
||||||
chatgptService: chatgptService,
|
chatgptService: chatgptService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,10 +309,19 @@ func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err := h.repo.Delete(id); err != nil {
|
||||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "Failed to delete account",
|
Message: "Failed to delete account: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,3 +331,150 @@ func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Message: "Account deleted successfully",
|
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 middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gpt-manager-go/internal/auth"
|
"gpt-manager-go/internal/auth"
|
||||||
@@ -31,7 +32,21 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
tokenString := parts[1]
|
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)
|
claims, err := auth.ParseToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ func (r *InvitationRepository) DeleteByEmailAndAccountID(email string, accountID
|
|||||||
return err
|
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查找邀请记录
|
// FindByAccountID 根据账号ID查找邀请记录
|
||||||
func (r *InvitationRepository) FindByAccountID(accountID int) ([]*models.Invitation, error) {
|
func (r *InvitationRepository) FindByAccountID(accountID int) ([]*models.Invitation, error) {
|
||||||
rows, err := r.db.Query(`
|
rows, err := r.db.Query(`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gpt-manager-go/internal/middleware"
|
"gpt-manager-go/internal/middleware"
|
||||||
"gpt-manager-go/internal/repository"
|
"gpt-manager-go/internal/repository"
|
||||||
"gpt-manager-go/internal/service"
|
"gpt-manager-go/internal/service"
|
||||||
|
"gpt-manager-go/internal/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRoutes 设置路由
|
// SetupRoutes 设置路由
|
||||||
@@ -25,7 +26,7 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
|||||||
|
|
||||||
// 初始化处理器
|
// 初始化处理器
|
||||||
authHandler := handler.NewAuthHandler(adminRepo)
|
authHandler := handler.NewAuthHandler(adminRepo)
|
||||||
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, chatgptService)
|
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, invitationRepo, chatgptService)
|
||||||
inviteHandler := handler.NewInviteHandler(chatgptAccountRepo, invitationRepo, cardKeyRepo, chatgptService)
|
inviteHandler := handler.NewInviteHandler(chatgptAccountRepo, invitationRepo, cardKeyRepo, chatgptService)
|
||||||
cardKeyHandler := handler.NewCardKeyHandler(cardKeyRepo)
|
cardKeyHandler := handler.NewCardKeyHandler(cardKeyRepo)
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
|||||||
protectedMux.HandleFunc("/api/accounts/create", accountHandler.Create)
|
protectedMux.HandleFunc("/api/accounts/create", accountHandler.Create)
|
||||||
protectedMux.HandleFunc("/api/accounts/refresh", accountHandler.Refresh)
|
protectedMux.HandleFunc("/api/accounts/refresh", accountHandler.Refresh)
|
||||||
protectedMux.HandleFunc("/api/accounts/delete", accountHandler.Delete)
|
protectedMux.HandleFunc("/api/accounts/delete", accountHandler.Delete)
|
||||||
|
protectedMux.HandleFunc("/api/accounts/batch/delete", accountHandler.BatchDelete)
|
||||||
|
protectedMux.HandleFunc("/api/accounts/batch/refresh", accountHandler.BatchRefresh)
|
||||||
|
|
||||||
// 邀请接口 (管理员) - GET: 列表, POST: 邀请, DELETE: 移除
|
// 邀请接口 (管理员) - GET: 列表, POST: 邀请, DELETE: 移除
|
||||||
protectedMux.HandleFunc("/api/invite", func(w http.ResponseWriter, r *http.Request) {
|
protectedMux.HandleFunc("/api/invite", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -86,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("/api/cardkeys/", middleware.AuthMiddleware(protectedMux))
|
mux.Handle("/api/cardkeys/", middleware.AuthMiddleware(protectedMux))
|
||||||
|
|
||||||
|
// 静态文件服务(前端)
|
||||||
|
mux.Handle("/", static.Handler())
|
||||||
|
|
||||||
// CORS 中间件包装
|
// CORS 中间件包装
|
||||||
return corsMiddleware(mux)
|
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:
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>mygo Team</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Account {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
seats_in_use: number
|
seats_in_use: number
|
||||||
seats_entitled: number
|
seats_entitled: number
|
||||||
|
active_until?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -56,3 +57,18 @@ export function refreshAccount(id: number) {
|
|||||||
export function deleteAccount(id: number) {
|
export function deleteAccount(id: number) {
|
||||||
return request.delete(`/api/accounts/delete?id=${id}`)
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface InviteByCardRequest {
|
|||||||
|
|
||||||
export interface Invitation {
|
export interface Invitation {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
invited_email: string
|
||||||
account_id: number
|
account_id: number
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -15,7 +15,7 @@ export interface Invitation {
|
|||||||
|
|
||||||
export interface InvitationsResponse {
|
export interface InvitationsResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
invitations?: Invitation[]
|
data?: Invitation[]
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,3 +37,20 @@ export function listInvitations(accountId: number) {
|
|||||||
export function deleteInvite(data: DeleteInviteRequest) {
|
export function deleteInvite(data: DeleteInviteRequest) {
|
||||||
return request.delete('/api/invite', { data })
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await getAccounts()
|
const response = await getAccounts()
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success) {
|
||||||
accounts.value = response.data.data
|
accounts.value = response.data.data || []
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.data.message || '获取账号列表失败')
|
throw new Error(response.data.message || '获取账号列表失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ async function loadCardKeys() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
||||||
if (response.data.success && response.data.keys) {
|
if (response.data.success) {
|
||||||
cardKeys.value = response.data.keys
|
cardKeys.value = response.data.keys || []
|
||||||
total.value = response.data.total || 0
|
total.value = response.data.total || 0
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.data.message || '获取卡密列表失败')
|
toast.error(response.data.message || '获取卡密列表失败')
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ async function loadInvitations() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await listInvitations(accountId.value)
|
const response = await listInvitations(accountId.value)
|
||||||
if (response.data.success && response.data.invitations) {
|
if (response.data.success) {
|
||||||
invitations.value = response.data.invitations
|
invitations.value = response.data.data || []
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.data.message || '获取邀请列表失败')
|
toast.error(response.data.message || '获取邀请列表失败')
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ async function handleDelete() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await deleteInvite({
|
const response = await deleteInvite({
|
||||||
email: invitation.email,
|
email: invitation.invited_email,
|
||||||
account_id: accountId.value,
|
account_id: accountId.value,
|
||||||
})
|
})
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -147,9 +147,11 @@ function goToPage(page: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageSizeChange(value: string) {
|
function handlePageSizeChange(value: any) {
|
||||||
pageSize.value = Number(value)
|
if (value) {
|
||||||
currentPage.value = 1
|
pageSize.value = Number(value)
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -197,7 +199,7 @@ function handlePageSizeChange(value: string) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="invitation in paginatedInvitations" :key="invitation.id">
|
<TableRow v-for="invitation in paginatedInvitations" :key="invitation.id">
|
||||||
<TableCell class="font-medium">
|
<TableCell class="font-medium">
|
||||||
{{ invitation.email }}
|
{{ invitation.invited_email }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
@@ -275,7 +277,7 @@ function handlePageSizeChange(value: string) {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
确定要删除用户 <strong>{{ pendingDelete?.email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
确定要删除用户 <strong>{{ pendingDelete?.invited_email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ import {
|
|||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import { createAccount, refreshAccount, deleteAccount, type Account } from '@/api/accounts'
|
import { createAccount, refreshAccount, deleteAccount, batchDeleteAccounts, batchRefreshAccounts, type Account } from '@/api/accounts'
|
||||||
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2 } from 'lucide-vue-next'
|
import { inviteByAdmin } from '@/api/invite'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2, UserPlus, Shuffle } from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accountsStore = useAccountsStore()
|
const accountsStore = useAccountsStore()
|
||||||
@@ -67,6 +69,18 @@ const showToken = ref(false)
|
|||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
const deleteDialogOpen = ref(false)
|
const deleteDialogOpen = ref(false)
|
||||||
|
|
||||||
|
// Invite dialog
|
||||||
|
const inviteDialogOpen = ref(false)
|
||||||
|
const invitingAccountId = ref<number | null>(null)
|
||||||
|
const invitingAccountName = ref('')
|
||||||
|
const inviteEmail = ref('')
|
||||||
|
const inviting = ref(false)
|
||||||
|
|
||||||
|
// Random invite dialog
|
||||||
|
const randomInviteDialogOpen = ref(false)
|
||||||
|
const randomInviteEmail = ref('')
|
||||||
|
const randomInviting = ref(false)
|
||||||
const pendingDelete = ref<Account | null>(null)
|
const pendingDelete = ref<Account | null>(null)
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -74,6 +88,12 @@ const currentPage = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const pageSizeOptions = [5, 10, 20, 50]
|
const pageSizeOptions = [5, 10, 20, 50]
|
||||||
|
|
||||||
|
// Selection for batch operations
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
const batchDeleting = ref(false)
|
||||||
|
const batchRefreshing = ref(false)
|
||||||
|
const batchDeleteDialogOpen = ref(false)
|
||||||
|
|
||||||
const totalPages = computed(() => Math.ceil(accountsStore.accounts.length / pageSize.value))
|
const totalPages = computed(() => Math.ceil(accountsStore.accounts.length / pageSize.value))
|
||||||
const paginatedAccounts = computed(() => {
|
const paginatedAccounts = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
@@ -185,15 +205,178 @@ function viewInvites(account: Account) {
|
|||||||
router.push(`/admin/teams/${account.id}/invites`)
|
router.push(`/admin/teams/${account.id}/invites`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInviteDialog(account: Account) {
|
||||||
|
invitingAccountId.value = account.id
|
||||||
|
invitingAccountName.value = account.name || account.team_account_id
|
||||||
|
inviteEmail.value = ''
|
||||||
|
inviteDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
if (!inviteEmail.value.trim()) {
|
||||||
|
toast.error('请输入邮箱地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!invitingAccountId.value) return
|
||||||
|
|
||||||
|
inviting.value = true
|
||||||
|
try {
|
||||||
|
const response = await inviteByAdmin({
|
||||||
|
email: inviteEmail.value.trim(),
|
||||||
|
account_id: invitingAccountId.value,
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('邀请发送成功')
|
||||||
|
inviteDialogOpen.value = false
|
||||||
|
inviteEmail.value = ''
|
||||||
|
// Refresh account to update seats
|
||||||
|
if (invitingAccountId.value) {
|
||||||
|
await handleRefresh(accountsStore.accounts.find(a => a.id === invitingAccountId.value)!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || '邀请失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '邀请失败')
|
||||||
|
} finally {
|
||||||
|
inviting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random invite - auto select available team
|
||||||
|
async function handleRandomInvite() {
|
||||||
|
if (!randomInviteEmail.value.trim()) {
|
||||||
|
toast.error('请输入邮箱地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
randomInviting.value = true
|
||||||
|
try {
|
||||||
|
// Use account_id = 0 to let backend auto-select
|
||||||
|
const response = await inviteByAdmin({
|
||||||
|
email: randomInviteEmail.value.trim(),
|
||||||
|
account_id: 0,
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(`邀请发送成功,已分配到: ${response.data.account_name || 'Team'}`)
|
||||||
|
randomInviteDialogOpen.value = false
|
||||||
|
randomInviteEmail.value = ''
|
||||||
|
// Refresh all accounts to update seats
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || '邀请失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '邀请失败')
|
||||||
|
} finally {
|
||||||
|
randomInviting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageSizeChange(value: string) {
|
function handlePageSizeChange(value: any) {
|
||||||
pageSize.value = Number(value)
|
if (value) {
|
||||||
currentPage.value = 1
|
pageSize.value = Number(value)
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string) {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
if (paginatedAccounts.value.length === 0) return false
|
||||||
|
return paginatedAccounts.value.every(a => selectedIds.value.includes(a.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
// Deselect all in current page
|
||||||
|
const pageIds = paginatedAccounts.value.map(a => a.id)
|
||||||
|
selectedIds.value = selectedIds.value.filter(id => !pageIds.includes(id))
|
||||||
|
} else {
|
||||||
|
// Select all in current page
|
||||||
|
const pageIds = paginatedAccounts.value.map(a => a.id)
|
||||||
|
const newIds = pageIds.filter(id => !selectedIds.value.includes(id))
|
||||||
|
selectedIds.value = [...selectedIds.value, ...newIds]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
if (selectedIds.value.includes(id)) {
|
||||||
|
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||||
|
} else {
|
||||||
|
selectedIds.value = [...selectedIds.value, id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmBatchDelete() {
|
||||||
|
if (selectedIds.value.length === 0) {
|
||||||
|
toast.error('请先选择要删除的 Team')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchDeleteDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (selectedIds.value.length === 0) return
|
||||||
|
|
||||||
|
batchDeleting.value = true
|
||||||
|
batchDeleteDialogOpen.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = [...selectedIds.value]
|
||||||
|
const response = await batchDeleteAccounts(ids)
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(`成功删除 ${response.data.success_count} 个 Team`)
|
||||||
|
} else {
|
||||||
|
toast.success(`删除完成: 成功 ${response.data.success_count} 个, 失败 ${response.data.failed_count} 个`)
|
||||||
|
}
|
||||||
|
clearSelection()
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
if (paginatedAccounts.value.length === 0 && currentPage.value > 1) {
|
||||||
|
currentPage.value--
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '批量删除失败')
|
||||||
|
} finally {
|
||||||
|
batchDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchRefresh() {
|
||||||
|
if (selectedIds.value.length === 0) {
|
||||||
|
toast.error('请先选择要刷新的 Team')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
batchRefreshing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = [...selectedIds.value]
|
||||||
|
const response = await batchRefreshAccounts(ids)
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(`成功刷新 ${response.data.success_count} 个 Team`)
|
||||||
|
} else {
|
||||||
|
toast.success(`刷新完成: 成功 ${response.data.success_count} 个, 失败 ${response.data.failed_count} 个`)
|
||||||
|
}
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '批量刷新失败')
|
||||||
|
} finally {
|
||||||
|
batchRefreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -201,6 +384,65 @@ function handlePageSizeChange(value: string) {
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Team 管理</h1>
|
<h1 class="text-2xl font-bold">Team 管理</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Batch operations - always show when there's data -->
|
||||||
|
<template v-if="accountsStore.accounts.length > 0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="handleBatchRefresh"
|
||||||
|
:disabled="selectedIds.length === 0 || batchRefreshing"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="batchRefreshing" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<RefreshCw v-else class="h-4 w-4 mr-2" />
|
||||||
|
刷新{{ selectedIds.length > 0 ? ` (${selectedIds.length})` : '' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
@click="confirmBatchDelete"
|
||||||
|
:disabled="selectedIds.length === 0 || batchDeleting"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="batchDeleting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Trash2 v-else class="h-4 w-4 mr-2" />
|
||||||
|
删除{{ selectedIds.length > 0 ? ` (${selectedIds.length})` : '' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Random Invite Button and Dialog -->
|
||||||
|
<Dialog v-model:open="randomInviteDialogOpen">
|
||||||
|
<DialogTrigger as-child>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Shuffle class="h-4 w-4 mr-2" />
|
||||||
|
随机邀请
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>随机邀请</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
系统将自动选择有空位的 Team 发送邀请
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="handleRandomInvite" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="random_invite_email">邮箱地址 *</Label>
|
||||||
|
<Input
|
||||||
|
id="random_invite_email"
|
||||||
|
v-model="randomInviteEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
:disabled="randomInviting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" :disabled="randomInviting">
|
||||||
|
<Loader2 v-if="randomInviting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{{ randomInviting ? '邀请中...' : '发送邀请' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog v-model:open="dialogOpen">
|
<Dialog v-model:open="dialogOpen">
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -266,6 +508,7 @@ function handlePageSizeChange(value: string) {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="min-h-[600px] flex flex-col">
|
<Card class="min-h-[600px] flex flex-col">
|
||||||
@@ -294,14 +537,27 @@ function handlePageSizeChange(value: string) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-12">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="isAllSelected"
|
||||||
|
@update:model-value="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>订阅状态</TableHead>
|
<TableHead>订阅状态</TableHead>
|
||||||
|
<TableHead>到期时间</TableHead>
|
||||||
<TableHead class="text-right">剩余席位</TableHead>
|
<TableHead class="text-right">剩余席位</TableHead>
|
||||||
<TableHead class="text-right">操作</TableHead>
|
<TableHead class="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="account in paginatedAccounts" :key="account.id">
|
<TableRow v-for="account in paginatedAccounts" :key="account.id" :class="{ 'bg-muted/50': selectedIds.includes(account.id) }">
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
:model-value="selectedIds.includes(account.id)"
|
||||||
|
@update:model-value="() => toggleSelect(account.id)"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-medium">
|
<TableCell class="font-medium">
|
||||||
{{ account.name || account.team_account_id }}
|
{{ account.name || account.team_account_id }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -310,6 +566,9 @@ function handlePageSizeChange(value: string) {
|
|||||||
{{ account.is_active ? '有效' : '无效' }}
|
{{ account.is_active ? '有效' : '无效' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">
|
||||||
|
{{ formatDate(account.active_until) }}
|
||||||
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="text-right">
|
||||||
{{ (account.seats_entitled || 0) - (account.seats_in_use || 0) }}
|
{{ (account.seats_entitled || 0) - (account.seats_in_use || 0) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -327,7 +586,10 @@ function handlePageSizeChange(value: string) {
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" @click="viewInvites(account)">
|
<Button variant="outline" size="sm" @click="openInviteDialog(account)" title="直接邀请">
|
||||||
|
<UserPlus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="viewInvites(account)" title="查看已邀请用户">
|
||||||
<Users class="h-4 w-4" />
|
<Users class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -408,5 +670,53 @@ function handlePageSizeChange(value: string) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<!-- Batch Delete confirmation dialog -->
|
||||||
|
<AlertDialog v-model:open="batchDeleteDialogOpen">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除选中的 <strong>{{ selectedIds.length }}</strong> 个 Team 吗?此操作不可撤销,相关的邀请记录也会被删除。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="handleBatchDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<!-- Invite dialog -->
|
||||||
|
<Dialog v-model:open="inviteDialogOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>邀请用户</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
邀请用户加入 Team: {{ invitingAccountName }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="handleInvite" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_email">邮箱地址 *</Label>
|
||||||
|
<Input
|
||||||
|
id="invite_email"
|
||||||
|
v-model="inviteEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
:disabled="inviting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" :disabled="inviting">
|
||||||
|
<Loader2 v-if="inviting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{{ inviting ? '邀请中...' : '发送邀请' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user