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/
|
||||
database_schema.md
|
||||
@@ -16,3 +16,6 @@ PORT=8080
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# API Token (用于外部 API 调用,可选)
|
||||
API_TOKEN=your-api-token-here
|
||||
|
||||
37
backend/Dockerfile
Normal file
37
backend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download all dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy the source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main ./cmd/main.go
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/.env.example .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run
|
||||
CMD ["./main"]
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -307,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
|
||||
}
|
||||
@@ -320,3 +331,150 @@ func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
Message: "Account deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
type BatchDeleteAccountRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchDeleteResponse 批量操作响应
|
||||
type BatchOperationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
}
|
||||
|
||||
// BatchDelete 批量删除账号
|
||||
func (h *ChatGPTAccountHandler) BatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchDeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "No accounts selected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, id := range req.IDs {
|
||||
// 先删除相关的邀请记录
|
||||
if err := h.invitationRepo.DeleteByAccountID(id); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
// 再删除账号
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BatchOperationResponse{
|
||||
Success: failedCount == 0,
|
||||
Message: "Batch delete completed",
|
||||
SuccessCount: successCount,
|
||||
FailedCount: failedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRefreshRequest 批量刷新请求
|
||||
type BatchRefreshRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchRefresh 批量刷新账号
|
||||
func (h *ChatGPTAccountHandler) BatchRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchRefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, BatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "No accounts selected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, id := range req.IDs {
|
||||
account, err := h.repo.FindByID(id)
|
||||
if err != nil || account == nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 ChatGPT API 获取订阅信息
|
||||
subInfo, err := h.chatgptService.GetSubscription(account.TeamAccountID, account.AuthToken)
|
||||
if err != nil {
|
||||
account.ConsecutiveFailures++
|
||||
account.IsActive = false
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
h.repo.Update(account)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新账号信息
|
||||
if subInfo.IsValid {
|
||||
account.SeatsInUse = subInfo.SeatsInUse
|
||||
account.SeatsEntitled = subInfo.SeatsEntitled
|
||||
account.ActiveStart = sql.NullTime{Time: subInfo.ActiveStart, Valid: !subInfo.ActiveStart.IsZero()}
|
||||
account.ActiveUntil = sql.NullTime{Time: subInfo.ActiveUntil, Valid: !subInfo.ActiveUntil.IsZero()}
|
||||
account.IsActive = true
|
||||
account.ConsecutiveFailures = 0
|
||||
} else {
|
||||
account.IsActive = false
|
||||
account.ConsecutiveFailures++
|
||||
}
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
|
||||
if err := h.repo.Update(account); err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BatchOperationResponse{
|
||||
Success: failedCount == 0,
|
||||
Message: "Batch refresh completed",
|
||||
SuccessCount: successCount,
|
||||
FailedCount: failedCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package 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)
|
||||
|
||||
@@ -82,6 +82,12 @@ func (r *InvitationRepository) DeleteByEmailAndAccountID(email string, 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(`
|
||||
|
||||
@@ -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,6 +47,8 @@ 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)
|
||||
|
||||
// 邀请接口 (管理员) - GET: 列表, POST: 邀请, DELETE: 移除
|
||||
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("/", static.Handler())
|
||||
|
||||
// CORS 中间件包装
|
||||
return corsMiddleware(mux)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import{d as t,h as o,n as r,u as n,v as c,x as l,o as p}from"./index-B0FmaMuw.js";const i=t({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(a,d)=>(p(),o("p",{"data-slot":"card-description",class:r(n(c)("text-muted-foreground text-sm",e.class))},[l(a.$slots,"default")],2))}});export{i as _};
|
||||
2
backend/internal/static/dist/assets/CardKeysPage-3J4JuRjk.js
vendored
Normal file
2
backend/internal/static/dist/assets/CardKeysPage-3J4JuRjk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{d as r,h as e,x as o,n as c,u as n,v as d,o as l}from"./index-B0FmaMuw.js";const _=r({__name:"Card",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card",class:c(n(d)("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",a.class))},[o(t.$slots,"default")],2))}}),i=r({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-content",class:c(n(d)("px-6",a.class))},[o(t.$slots,"default")],2))}}),m=r({__name:"CardHeader",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-header",class:c(n(d)("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",a.class))},[o(t.$slots,"default")],2))}}),f=r({__name:"CardTitle",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("h3",{"data-slot":"card-title",class:c(n(d)("leading-none font-semibold",a.class))},[o(t.$slots,"default")],2))}});export{_,m as a,f as b,i as c};
|
||||
1
backend/internal/static/dist/assets/Checkbox.vue_vue_type_script_setup_true_lang-BEbtniid.js
vendored
Normal file
1
backend/internal/static/dist/assets/Checkbox.vue_vue_type_script_setup_true_lang-BEbtniid.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/DashboardPage-C_e1hozu.js
vendored
Normal file
1
backend/internal/static/dist/assets/DashboardPage-C_e1hozu.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as b,d as w,p as k,j as g,h as i,f as d,a as r,i as v,b as t,w as s,u as e,_ as h,e as n,n as C,U as j,t as c,o}from"./index-B0FmaMuw.js";import{_ as f,a as m,b as _,c as u}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as p}from"./Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js";import{u as M}from"./accounts-CLfPgj8J.js";import{R as V}from"./refresh-cw-Bst35UPe.js";import{C as $}from"./circle-x-C8-4gjQR.js";const z=b("armchair",[["path",{d:"M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3",key:"irtipd"}],["path",{d:"M3 16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",key:"1qyhux"}],["path",{d:"M5 18v2",key:"ppbyun"}],["path",{d:"M19 18v2",key:"gy7782"}]]);const A=b("circle-check-big",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]]),B={class:"space-y-6"},T={class:"flex items-center justify-between"},D={class:"grid gap-4 md:grid-cols-2 lg:grid-cols-4"},N={key:1,class:"text-2xl font-bold"},S={key:1,class:"text-2xl font-bold text-green-600"},L={key:1,class:"text-2xl font-bold text-red-600"},R={key:1,class:"text-2xl font-bold"},U={class:"flex items-center justify-between"},q={class:"text-destructive"},J=w({__name:"DashboardPage",setup(E){const l=M();k(()=>{x()});async function x(){try{await l.fetchAccounts()}catch(y){g.error(y.message||"加载数据失败")}}return(y,a)=>(o(),i("div",B,[d("div",T,[a[1]||(a[1]=d("h1",{class:"text-2xl font-bold"},"Dashboard",-1)),t(e(h),{variant:"outline",size:"sm",onClick:x,disabled:e(l).loading},{default:s(()=>[t(e(V),{class:C(["h-4 w-4 mr-2",e(l).loading&&"animate-spin"])},null,8,["class"]),a[0]||(a[0]=n(" 刷新 ",-1))]),_:1},8,["disabled"])]),d("div",D,[t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[2]||(a[2]=[n("Team 总数",-1)])]),_:1}),t(e(j),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",N,c(e(l).totalTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[3]||(a[3]=[n("有效订阅",-1)])]),_:1}),t(e(A),{class:"h-4 w-4 text-green-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",S,c(e(l).validTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[4]||(a[4]=[n("无效订阅",-1)])]),_:1}),t(e($),{class:"h-4 w-4 text-red-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",L,c(e(l).invalidTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[5]||(a[5]=[n("剩余席位",-1)])]),_:1}),t(e(z),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",R,c(e(l).totalAvailableSeats),1))]),_:1})]),_:1})]),e(l).error?(o(),r(e(f),{key:0,class:"border-destructive"},{default:s(()=>[t(e(u),{class:"pt-6"},{default:s(()=>[d("div",U,[d("p",q,c(e(l).error),1),t(e(h),{variant:"outline",size:"sm",onClick:x},{default:s(()=>[...a[6]||(a[6]=[n(" 重试 ",-1)])]),_:1})])]),_:1})]),_:1})):v("",!0)]))}});export{J as default};
|
||||
1
backend/internal/static/dist/assets/JoinPage-mJKO0v_Y.js
vendored
Normal file
1
backend/internal/static/dist/assets/JoinPage-mJKO0v_Y.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as C,d as w,r as c,a as f,u as a,w as t,o as d,b as l,e as n,f as p,g as V,h as $,i as _,n as h,t as y,_ as B,j as i}from"./index-B0FmaMuw.js";import{_ as N,a as T,b as K,c as L}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as S}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_ as g,a as k}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{i as z}from"./invite-DvsN2S4N.js";import{C as P}from"./circle-x-C8-4gjQR.js";import{L as U}from"./index-DwEwynZa.js";const j=C("circle-check",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]),D={class:"space-y-2"},E={class:"space-y-2"},F=w({__name:"JoinPage",setup(G){const u=c(""),o=c(""),r=c(!1),s=c(null),x=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;async function b(){if(!u.value.trim()){i.error("请输入邮箱");return}if(!x.test(u.value)){i.error("邮箱格式不正确");return}if(!o.value.trim()){i.error("请输入卡密");return}r.value=!0,s.value=null;try{const m=await z({email:u.value.trim(),card_key:o.value.trim()});m.data.success?(s.value={success:!0,message:"提交成功,已发起邀请!"},i.success("提交成功"),u.value="",o.value=""):(s.value={success:!1,message:m.data.message||"提交失败,请检查卡密或邮箱"},i.error(s.value.message))}catch(m){const e=m.response?.data?.message||"提交失败,请检查卡密或邮箱";s.value={success:!1,message:e},i.error(e)}finally{r.value=!1}}return(m,e)=>(d(),f(a(N),{class:"w-full max-w-md mx-4"},{default:t(()=>[l(a(T),{class:"text-center"},{default:t(()=>[l(a(K),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[n("加入 Team",-1)])]),_:1}),l(a(S),null,{default:t(()=>[...e[3]||(e[3]=[n("输入邮箱和卡密,即可加入 ChatGPT Team",-1)])]),_:1})]),_:1}),l(a(L),null,{default:t(()=>[p("form",{onSubmit:V(b,["prevent"]),class:"space-y-4"},[p("div",D,[l(a(g),{for:"email"},{default:t(()=>[...e[4]||(e[4]=[n("邮箱",-1)])]),_:1}),l(a(k),{id:"email",modelValue:u.value,"onUpdate:modelValue":e[0]||(e[0]=v=>u.value=v),type:"email",placeholder:"your@email.com",disabled:r.value},null,8,["modelValue","disabled"])]),p("div",E,[l(a(g),{for:"cardKey"},{default:t(()=>[...e[5]||(e[5]=[n("卡密",-1)])]),_:1}),l(a(k),{id:"cardKey",modelValue:o.value,"onUpdate:modelValue":e[1]||(e[1]=v=>o.value=v),type:"text",placeholder:"请输入卡密",disabled:r.value},null,8,["modelValue","disabled"])]),s.value?(d(),$("div",{key:0,class:h(["flex items-center gap-2 p-3 rounded-lg text-sm",s.value.success?"bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300":"bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300"])},[s.value.success?(d(),f(a(j),{key:0,class:"h-4 w-4 shrink-0"})):(d(),f(a(P),{key:1,class:"h-4 w-4 shrink-0"})),p("span",null,y(s.value.message),1)],2)):_("",!0),l(a(B),{type:"submit",class:"w-full",disabled:r.value},{default:t(()=>[r.value?(d(),f(a(U),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):_("",!0),n(" "+y(r.value?"提交中...":"提交"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{F as default};
|
||||
1
backend/internal/static/dist/assets/Label.vue_vue_type_script_setup_true_lang-duvmWwej.js
vendored
Normal file
1
backend/internal/static/dist/assets/Label.vue_vue_type_script_setup_true_lang-duvmWwej.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as m,a as V,o as v,w as $,x as B,B as C,u as d,P as O,L as P,r as q,I as h,q as E,H as M,M as D,N as F,h as I,n as J,v as S,O as k}from"./index-B0FmaMuw.js";import{a as T,i as U,r as z}from"./index-DwEwynZa.js";var H=m({__name:"Label",props:{for:{type:String,required:!1},asChild:{type:Boolean,required:!1},as:{type:null,required:!1,default:"label"}},setup(a){const s=a;return T(),(i,n)=>(v(),V(d(O),C(s,{onMousedown:n[0]||(n[0]=l=>{!l.defaultPrevented&&l.detail>1&&l.preventDefault()})}),{default:$(()=>[B(i.$slots,"default")]),_:3},16))}}),R=H;function j(a){return JSON.parse(JSON.stringify(a))}function A(a,s,i,n={}){var l,r;const{clone:o=!1,passive:c=!1,eventName:L,deep:b=!1,defaultValue:N,shouldEmit:g}=n,e=P(),_=i||e?.emit||(e==null||(l=e.$emit)===null||l===void 0?void 0:l.bind(e))||(e==null||(r=e.proxy)===null||r===void 0||(r=r.$emit)===null||r===void 0?void 0:r.bind(e?.proxy));let u=L;u=u||`update:${s.toString()}`;const x=t=>o?typeof o=="function"?o(t):j(t):t,y=()=>U(a[s])?x(a[s]):N,w=t=>{g?g(t)&&_(u,t):_(u,t)};if(c){const t=q(y());let p=!1;return h(()=>a[s],f=>{p||(p=!0,t.value=x(f),M(()=>p=!1))}),h(t,f=>{!p&&(f!==a[s]||b)&&w(f)},{deep:b}),t}else return E({get(){return y()},set(t){w(t)}})}const Q=m({__name:"Input",props:{defaultValue:{},modelValue:{},class:{}},emits:["update:modelValue"],setup(a,{emit:s}){const i=a,l=A(i,"modelValue",s,{passive:!0,defaultValue:i.defaultValue});return(r,o)=>D((v(),I("input",{"onUpdate:modelValue":o[0]||(o[0]=c=>k(l)?l.value=c:null),"data-slot":"input",class:J(d(S)("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm","focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]","aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",i.class))},null,2)),[[F,d(l)]])}}),W=m({__name:"Label",props:{for:{},asChild:{type:Boolean},as:{},class:{}},setup(a){const s=a,i=z(s,"class");return(n,l)=>(v(),V(d(R),C({"data-slot":"label"},d(i),{class:d(S)("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",s.class)}),{default:$(()=>[B(n.$slots,"default")]),_:3},16,["class"]))}});export{W as _,Q as a};
|
||||
1
backend/internal/static/dist/assets/LoginPage-j_vZojoK.js
vendored
Normal file
1
backend/internal/static/dist/assets/LoginPage-j_vZojoK.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as V,k as g,r as i,a as c,w as t,u as a,l as $,m as k,o as p,b as s,e as o,f as m,g as C,_ as L,i as N,t as S,j as f}from"./index-B0FmaMuw.js";import{_ as B,a as R,b as U,c as h}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as j}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_,a as v}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{L as q}from"./index-DwEwynZa.js";const A={class:"space-y-2"},D={class:"space-y-2"},G=V({__name:"LoginPage",setup(M){const w=$(),b=k(),x=g(),r=i(""),u=i(""),l=i(!1);async function y(){if(!r.value.trim()||!u.value.trim()){f.error("请输入账号和密码");return}l.value=!0;const d=await x.login({username:r.value.trim(),password:u.value});if(l.value=!1,d.success){f.success("登录成功");const e=b.query.redirect;w.push(e||"/admin/dashboard")}else f.error(d.message||"登录失败")}return(d,e)=>(p(),c(a(B),{class:"w-full max-w-md mx-4"},{default:t(()=>[s(a(R),{class:"text-center"},{default:t(()=>[s(a(U),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[o("管理后台登录",-1)])]),_:1}),s(a(j),null,{default:t(()=>[...e[3]||(e[3]=[o("请输入您的账号和密码",-1)])]),_:1})]),_:1}),s(a(h),null,{default:t(()=>[m("form",{onSubmit:C(y,["prevent"]),class:"space-y-4"},[m("div",A,[s(a(_),{for:"username"},{default:t(()=>[...e[4]||(e[4]=[o("账号",-1)])]),_:1}),s(a(v),{id:"username",modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=n=>r.value=n),type:"text",placeholder:"请输入账号",disabled:l.value,autocomplete:"username"},null,8,["modelValue","disabled"])]),m("div",D,[s(a(_),{for:"password"},{default:t(()=>[...e[5]||(e[5]=[o("密码",-1)])]),_:1}),s(a(v),{id:"password",modelValue:u.value,"onUpdate:modelValue":e[1]||(e[1]=n=>u.value=n),type:"password",placeholder:"请输入密码",disabled:l.value,autocomplete:"current-password"},null,8,["modelValue","disabled"])]),s(a(L),{type:"submit",class:"w-full",disabled:l.value},{default:t(()=>[l.value?(p(),c(a(q),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):N("",!0),o(" "+S(l.value?"登录中...":"登录"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{G as default};
|
||||
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js
vendored
Normal file
1
backend/internal/static/dist/assets/Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{d as a,h as n,n as o,u as t,v as r,o as l}from"./index-B0FmaMuw.js";const u=a({__name:"Skeleton",props:{class:{}},setup(s){const e=s;return(c,p)=>(l(),n("div",{"data-slot":"skeleton",class:o(t(r)("animate-pulse rounded-md bg-primary/10",e.class))},null,2))}});export{u as _};
|
||||
1
backend/internal/static/dist/assets/TeamInvitesPage-DMa0gCmF.js
vendored
Normal file
1
backend/internal/static/dist/assets/TeamInvitesPage-DMa0gCmF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/TeamsPage-3k-Ffe4V.js
vendored
Normal file
1
backend/internal/static/dist/assets/TeamsPage-3k-Ffe4V.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/accounts-CLfPgj8J.js
vendored
Normal file
1
backend/internal/static/dist/assets/accounts-CLfPgj8J.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{y as s,z as p,r as o,q as r}from"./index-B0FmaMuw.js";function m(e){const a=new URLSearchParams().toString();return s.get(`/api/accounts${a?`?${a}`:""}`)}function A(e){return s.post("/api/accounts/create",e)}function b(e){return s.post(`/api/accounts/refresh?id=${e}`)}function w(e){return s.delete(`/api/accounts/delete?id=${e}`)}function y(e){return s.delete("/api/accounts/batch/delete",{data:{ids:e}})}function S(e){return s.post("/api/accounts/batch/refresh",{ids:e})}const _=p("accounts",()=>{const e=o([]),n=o(!1),a=o(null),u=r(()=>e.value.length),i=r(()=>e.value.filter(t=>t.is_active).length),l=r(()=>e.value.filter(t=>!t.is_active).length),d=r(()=>e.value.reduce((t,c)=>t+Math.max(0,(c.seats_entitled||0)-(c.seats_in_use||0)),0));async function f(){n.value=!0,a.value=null;try{const t=await m();if(t.data.success)e.value=t.data.data||[];else throw new Error(t.data.message||"获取账号列表失败")}catch(t){throw a.value=t.message||"获取账号列表失败",t}finally{n.value=!1}}function h(t){const c=e.value.findIndex(v=>v.id===t.id);c!==-1&&(e.value[c]=t)}return{accounts:e,loading:n,error:a,totalTeams:u,validTeams:i,invalidTeams:l,totalAvailableSeats:d,fetchAccounts:f,updateAccount:h}});export{S as a,y as b,A as c,w as d,b as r,_ as u};
|
||||
1
backend/internal/static/dist/assets/circle-x-C8-4gjQR.js
vendored
Normal file
1
backend/internal/static/dist/assets/circle-x-C8-4gjQR.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c}from"./index-B0FmaMuw.js";const r=c("circle-x",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m15 9-6 6",key:"1uzhvr"}],["path",{d:"m9 9 6 6",key:"z0biqf"}]]);export{r as C};
|
||||
7
backend/internal/static/dist/assets/index-B0FmaMuw.js
vendored
Normal file
7
backend/internal/static/dist/assets/index-B0FmaMuw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/index-C_xOPDav.css
vendored
Normal file
1
backend/internal/static/dist/assets/index-C_xOPDav.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/index-DwEwynZa.js
vendored
Normal file
1
backend/internal/static/dist/assets/index-DwEwynZa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/internal/static/dist/assets/invite-DvsN2S4N.js
vendored
Normal file
1
backend/internal/static/dist/assets/invite-DvsN2S4N.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{y as t}from"./index-B0FmaMuw.js";function e(i){return t.post("/api/invite/card",i)}function a(i){return t.get(`/api/invite?account_id=${i}`)}function r(i){return t.delete("/api/invite",{data:i})}function o(i){return t.post("/api/invite",i)}export{o as a,r as d,e as i,a as l};
|
||||
1
backend/internal/static/dist/assets/refresh-cw-Bst35UPe.js
vendored
Normal file
1
backend/internal/static/dist/assets/refresh-cw-Bst35UPe.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{c as e}from"./index-B0FmaMuw.js";const t=e("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);export{t as R};
|
||||
14
backend/internal/static/dist/index.html
vendored
Normal file
14
backend/internal/static/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mygo Team</title>
|
||||
<script type="module" crossorigin src="/assets/index-B0FmaMuw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_xOPDav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/internal/static/dist/vite.svg
vendored
Normal file
1
backend/internal/static/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
56
backend/internal/static/static.go
Normal file
56
backend/internal/static/static.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var StaticFiles embed.FS
|
||||
|
||||
// Handler 返回静态文件处理器
|
||||
// 用于服务嵌入的前端静态文件
|
||||
func Handler() http.Handler {
|
||||
// 提取 dist 子目录
|
||||
distFS, err := fs.Sub(StaticFiles, "dist")
|
||||
if err != nil {
|
||||
// 如果 dist 目录不存在,返回空处理器
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Static files not available", http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 对于 API 路径,不处理
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试服务静态文件
|
||||
// 对于 SPA,如果文件不存在,返回 index.html
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
_, err := fs.Stat(distFS, strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
// 文件不存在,返回 index.html(支持 SPA 路由)
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// IsAvailable 检查静态文件是否可用
|
||||
func IsAvailable() bool {
|
||||
_, err := fs.Sub(StaticFiles, "dist")
|
||||
return err == nil
|
||||
}
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
app:
|
||||
container_name: gpt-manager
|
||||
build: ./backend
|
||||
ports:
|
||||
- "${EXPOSE_PORT}:8080"
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PORT=${PORT}
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
container_name: gpt-manager-db
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>frontend</title>
|
||||
<title>mygo Team</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Account {
|
||||
is_active: boolean
|
||||
seats_in_use: number
|
||||
seats_entitled: number
|
||||
active_until?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -56,3 +57,18 @@ export function refreshAccount(id: number) {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface InviteByCardRequest {
|
||||
|
||||
export interface Invitation {
|
||||
id: number
|
||||
email: string
|
||||
invited_email: string
|
||||
account_id: number
|
||||
status: string
|
||||
created_at: string
|
||||
@@ -15,7 +15,7 @@ export interface Invitation {
|
||||
|
||||
export interface InvitationsResponse {
|
||||
success: boolean
|
||||
invitations?: Invitation[]
|
||||
data?: Invitation[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
@@ -37,3 +37,20 @@ export function listInvitations(accountId: number) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
error.value = null
|
||||
try {
|
||||
const response = await getAccounts()
|
||||
if (response.data.success && response.data.data) {
|
||||
accounts.value = response.data.data
|
||||
if (response.data.success) {
|
||||
accounts.value = response.data.data || []
|
||||
} else {
|
||||
throw new Error(response.data.message || '获取账号列表失败')
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ async function loadCardKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
||||
if (response.data.success && response.data.keys) {
|
||||
cardKeys.value = response.data.keys
|
||||
if (response.data.success) {
|
||||
cardKeys.value = response.data.keys || []
|
||||
total.value = response.data.total || 0
|
||||
} else {
|
||||
toast.error(response.data.message || '获取卡密列表失败')
|
||||
|
||||
@@ -77,8 +77,8 @@ async function loadInvitations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await listInvitations(accountId.value)
|
||||
if (response.data.success && response.data.invitations) {
|
||||
invitations.value = response.data.invitations
|
||||
if (response.data.success) {
|
||||
invitations.value = response.data.data || []
|
||||
} else {
|
||||
toast.error(response.data.message || '获取邀请列表失败')
|
||||
}
|
||||
@@ -114,7 +114,7 @@ async function handleDelete() {
|
||||
|
||||
try {
|
||||
const response = await deleteInvite({
|
||||
email: invitation.email,
|
||||
email: invitation.invited_email,
|
||||
account_id: accountId.value,
|
||||
})
|
||||
if (response.data.success) {
|
||||
@@ -147,9 +147,11 @@ function goToPage(page: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(value: string) {
|
||||
pageSize.value = Number(value)
|
||||
currentPage.value = 1
|
||||
function handlePageSizeChange(value: any) {
|
||||
if (value) {
|
||||
pageSize.value = Number(value)
|
||||
currentPage.value = 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -197,7 +199,7 @@ function handlePageSizeChange(value: string) {
|
||||
<TableBody>
|
||||
<TableRow v-for="invitation in paginatedInvitations" :key="invitation.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ invitation.email }}
|
||||
{{ invitation.invited_email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
@@ -275,7 +277,7 @@ function handlePageSizeChange(value: string) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 <strong>{{ pendingDelete?.email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||
确定要删除用户 <strong>{{ pendingDelete?.invited_email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -53,8 +53,10 @@ import {
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { createAccount, refreshAccount, deleteAccount, type Account } from '@/api/accounts'
|
||||
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2 } from 'lucide-vue-next'
|
||||
import { createAccount, refreshAccount, deleteAccount, batchDeleteAccounts, batchRefreshAccounts, type Account } from '@/api/accounts'
|
||||
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 accountsStore = useAccountsStore()
|
||||
@@ -67,6 +69,18 @@ const showToken = ref(false)
|
||||
|
||||
// Delete confirmation
|
||||
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)
|
||||
|
||||
// Pagination
|
||||
@@ -74,6 +88,12 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
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 paginatedAccounts = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
@@ -185,15 +205,178 @@ function viewInvites(account: Account) {
|
||||
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) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(value: string) {
|
||||
pageSize.value = Number(value)
|
||||
currentPage.value = 1
|
||||
function handlePageSizeChange(value: any) {
|
||||
if (value) {
|
||||
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>
|
||||
|
||||
@@ -201,6 +384,65 @@ function handlePageSizeChange(value: string) {
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<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">
|
||||
<DialogTrigger as-child>
|
||||
<Button>
|
||||
@@ -266,6 +508,7 @@ function handlePageSizeChange(value: string) {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="min-h-[600px] flex flex-col">
|
||||
@@ -294,14 +537,27 @@ function handlePageSizeChange(value: string) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-12">
|
||||
<Checkbox
|
||||
:model-value="isAllSelected"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>订阅状态</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
<TableHead class="text-right">剩余席位</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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">
|
||||
{{ account.name || account.team_account_id }}
|
||||
</TableCell>
|
||||
@@ -310,6 +566,9 @@ function handlePageSizeChange(value: string) {
|
||||
{{ account.is_active ? '有效' : '无效' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatDate(account.active_until) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ (account.seats_entitled || 0) - (account.seats_in_use || 0) }}
|
||||
</TableCell>
|
||||
@@ -327,7 +586,10 @@ function handlePageSizeChange(value: string) {
|
||||
]"
|
||||
/>
|
||||
</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" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -408,5 +670,53 @@ function handlePageSizeChange(value: string) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user