Compare commits

...

6 Commits

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

1
.gitignore vendored
View File

@@ -41,4 +41,3 @@ coverage.html
# 开发文档
document/
database_schema.md

View File

@@ -16,3 +16,6 @@ PORT=8080
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
# API Token (用于外部 API 调用,可选)
API_TOKEN=your-api-token-here

37
backend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download all dependencies
RUN go mod download
# Copy the source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main ./cmd/main.go
# Final stage
FROM alpine:latest
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates tzdata
# Copy binary from builder
COPY --from=builder /app/main .
COPY --from=builder /app/.env.example .
# Expose port
EXPOSE 8080
# Command to run
CMD ["./main"]

View File

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

View File

@@ -3,6 +3,7 @@ package middleware
import (
"context"
"net/http"
"os"
"strings"
"gpt-manager-go/internal/auth"
@@ -31,7 +32,21 @@ func AuthMiddleware(next http.Handler) http.Handler {
tokenString := parts[1]
// 解析 Token
// 首先检查是否是 API Token
apiToken := os.Getenv("API_TOKEN")
if apiToken != "" && tokenString == apiToken {
// API Token 认证成功,创建虚拟管理员上下文
claims := &auth.Claims{
UserID: 0,
Username: "api_token",
IsSuperAdmin: true,
}
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// 解析 JWT Token
claims, err := auth.ParseToken(tokenString)
if err != nil {
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)

View File

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

View File

@@ -8,6 +8,7 @@ import (
"gpt-manager-go/internal/middleware"
"gpt-manager-go/internal/repository"
"gpt-manager-go/internal/service"
"gpt-manager-go/internal/static"
)
// SetupRoutes 设置路由
@@ -25,7 +26,7 @@ func SetupRoutes(db *sql.DB) http.Handler {
// 初始化处理器
authHandler := handler.NewAuthHandler(adminRepo)
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, chatgptService)
accountHandler := handler.NewChatGPTAccountHandler(chatgptAccountRepo, invitationRepo, chatgptService)
inviteHandler := handler.NewInviteHandler(chatgptAccountRepo, invitationRepo, cardKeyRepo, chatgptService)
cardKeyHandler := handler.NewCardKeyHandler(cardKeyRepo)
@@ -46,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)
}

View File

@@ -0,0 +1 @@
import{d as t,h as o,n as r,u as n,v as c,x as l,o as p}from"./index-B0FmaMuw.js";const i=t({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(a,d)=>(p(),o("p",{"data-slot":"card-description",class:r(n(c)("text-muted-foreground text-sm",e.class))},[l(a.$slots,"default")],2))}});export{i as _};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{d as r,h as e,x as o,n as c,u as n,v as d,o as l}from"./index-B0FmaMuw.js";const _=r({__name:"Card",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card",class:c(n(d)("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",a.class))},[o(t.$slots,"default")],2))}}),i=r({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-content",class:c(n(d)("px-6",a.class))},[o(t.$slots,"default")],2))}}),m=r({__name:"CardHeader",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("div",{"data-slot":"card-header",class:c(n(d)("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",a.class))},[o(t.$slots,"default")],2))}}),f=r({__name:"CardTitle",props:{class:{}},setup(s){const a=s;return(t,p)=>(l(),e("h3",{"data-slot":"card-title",class:c(n(d)("leading-none font-semibold",a.class))},[o(t.$slots,"default")],2))}});export{_,m as a,f as b,i as c};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{c as b,d as w,p as k,j as g,h as i,f as d,a as r,i as v,b as t,w as s,u as e,_ as h,e as n,n as C,U as j,t as c,o}from"./index-B0FmaMuw.js";import{_ as f,a as m,b as _,c as u}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as p}from"./Skeleton.vue_vue_type_script_setup_true_lang-CypbIxgo.js";import{u as M}from"./accounts-CLfPgj8J.js";import{R as V}from"./refresh-cw-Bst35UPe.js";import{C as $}from"./circle-x-C8-4gjQR.js";const z=b("armchair",[["path",{d:"M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3",key:"irtipd"}],["path",{d:"M3 16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",key:"1qyhux"}],["path",{d:"M5 18v2",key:"ppbyun"}],["path",{d:"M19 18v2",key:"gy7782"}]]);const A=b("circle-check-big",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]]),B={class:"space-y-6"},T={class:"flex items-center justify-between"},D={class:"grid gap-4 md:grid-cols-2 lg:grid-cols-4"},N={key:1,class:"text-2xl font-bold"},S={key:1,class:"text-2xl font-bold text-green-600"},L={key:1,class:"text-2xl font-bold text-red-600"},R={key:1,class:"text-2xl font-bold"},U={class:"flex items-center justify-between"},q={class:"text-destructive"},J=w({__name:"DashboardPage",setup(E){const l=M();k(()=>{x()});async function x(){try{await l.fetchAccounts()}catch(y){g.error(y.message||"加载数据失败")}}return(y,a)=>(o(),i("div",B,[d("div",T,[a[1]||(a[1]=d("h1",{class:"text-2xl font-bold"},"Dashboard",-1)),t(e(h),{variant:"outline",size:"sm",onClick:x,disabled:e(l).loading},{default:s(()=>[t(e(V),{class:C(["h-4 w-4 mr-2",e(l).loading&&"animate-spin"])},null,8,["class"]),a[0]||(a[0]=n(" 刷新 ",-1))]),_:1},8,["disabled"])]),d("div",D,[t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[2]||(a[2]=[n("Team 总数",-1)])]),_:1}),t(e(j),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",N,c(e(l).totalTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[3]||(a[3]=[n("有效订阅",-1)])]),_:1}),t(e(A),{class:"h-4 w-4 text-green-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",S,c(e(l).validTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[4]||(a[4]=[n("无效订阅",-1)])]),_:1}),t(e($),{class:"h-4 w-4 text-red-500"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",L,c(e(l).invalidTeams),1))]),_:1})]),_:1}),t(e(f),null,{default:s(()=>[t(e(m),{class:"flex flex-row items-center justify-between space-y-0 pb-2"},{default:s(()=>[t(e(_),{class:"text-sm font-medium"},{default:s(()=>[...a[5]||(a[5]=[n("剩余席位",-1)])]),_:1}),t(e(z),{class:"h-4 w-4 text-muted-foreground"})]),_:1}),t(e(u),null,{default:s(()=>[e(l).loading?(o(),r(e(p),{key:0,class:"h-8 w-16"})):(o(),i("div",R,c(e(l).totalAvailableSeats),1))]),_:1})]),_:1})]),e(l).error?(o(),r(e(f),{key:0,class:"border-destructive"},{default:s(()=>[t(e(u),{class:"pt-6"},{default:s(()=>[d("div",U,[d("p",q,c(e(l).error),1),t(e(h),{variant:"outline",size:"sm",onClick:x},{default:s(()=>[...a[6]||(a[6]=[n(" 重试 ",-1)])]),_:1})])]),_:1})]),_:1})):v("",!0)]))}});export{J as default};

View File

@@ -0,0 +1 @@
import{c as C,d as w,r as c,a as f,u as a,w as t,o as d,b as l,e as n,f as p,g as V,h as $,i as _,n as h,t as y,_ as B,j as i}from"./index-B0FmaMuw.js";import{_ as N,a as T,b as K,c as L}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as S}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_ as g,a as k}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{i as z}from"./invite-DvsN2S4N.js";import{C as P}from"./circle-x-C8-4gjQR.js";import{L as U}from"./index-DwEwynZa.js";const j=C("circle-check",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]),D={class:"space-y-2"},E={class:"space-y-2"},F=w({__name:"JoinPage",setup(G){const u=c(""),o=c(""),r=c(!1),s=c(null),x=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;async function b(){if(!u.value.trim()){i.error("请输入邮箱");return}if(!x.test(u.value)){i.error("邮箱格式不正确");return}if(!o.value.trim()){i.error("请输入卡密");return}r.value=!0,s.value=null;try{const m=await z({email:u.value.trim(),card_key:o.value.trim()});m.data.success?(s.value={success:!0,message:"提交成功,已发起邀请!"},i.success("提交成功"),u.value="",o.value=""):(s.value={success:!1,message:m.data.message||"提交失败,请检查卡密或邮箱"},i.error(s.value.message))}catch(m){const e=m.response?.data?.message||"提交失败,请检查卡密或邮箱";s.value={success:!1,message:e},i.error(e)}finally{r.value=!1}}return(m,e)=>(d(),f(a(N),{class:"w-full max-w-md mx-4"},{default:t(()=>[l(a(T),{class:"text-center"},{default:t(()=>[l(a(K),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[n("加入 Team",-1)])]),_:1}),l(a(S),null,{default:t(()=>[...e[3]||(e[3]=[n("输入邮箱和卡密,即可加入 ChatGPT Team",-1)])]),_:1})]),_:1}),l(a(L),null,{default:t(()=>[p("form",{onSubmit:V(b,["prevent"]),class:"space-y-4"},[p("div",D,[l(a(g),{for:"email"},{default:t(()=>[...e[4]||(e[4]=[n("邮箱",-1)])]),_:1}),l(a(k),{id:"email",modelValue:u.value,"onUpdate:modelValue":e[0]||(e[0]=v=>u.value=v),type:"email",placeholder:"your@email.com",disabled:r.value},null,8,["modelValue","disabled"])]),p("div",E,[l(a(g),{for:"cardKey"},{default:t(()=>[...e[5]||(e[5]=[n("卡密",-1)])]),_:1}),l(a(k),{id:"cardKey",modelValue:o.value,"onUpdate:modelValue":e[1]||(e[1]=v=>o.value=v),type:"text",placeholder:"请输入卡密",disabled:r.value},null,8,["modelValue","disabled"])]),s.value?(d(),$("div",{key:0,class:h(["flex items-center gap-2 p-3 rounded-lg text-sm",s.value.success?"bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300":"bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300"])},[s.value.success?(d(),f(a(j),{key:0,class:"h-4 w-4 shrink-0"})):(d(),f(a(P),{key:1,class:"h-4 w-4 shrink-0"})),p("span",null,y(s.value.message),1)],2)):_("",!0),l(a(B),{type:"submit",class:"w-full",disabled:r.value},{default:t(()=>[r.value?(d(),f(a(U),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):_("",!0),n(" "+y(r.value?"提交中...":"提交"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{F as default};

View File

@@ -0,0 +1 @@
import{d as m,a as V,o as v,w as $,x as B,B as C,u as d,P as O,L as P,r as q,I as h,q as E,H as M,M as D,N as F,h as I,n as J,v as S,O as k}from"./index-B0FmaMuw.js";import{a as T,i as U,r as z}from"./index-DwEwynZa.js";var H=m({__name:"Label",props:{for:{type:String,required:!1},asChild:{type:Boolean,required:!1},as:{type:null,required:!1,default:"label"}},setup(a){const s=a;return T(),(i,n)=>(v(),V(d(O),C(s,{onMousedown:n[0]||(n[0]=l=>{!l.defaultPrevented&&l.detail>1&&l.preventDefault()})}),{default:$(()=>[B(i.$slots,"default")]),_:3},16))}}),R=H;function j(a){return JSON.parse(JSON.stringify(a))}function A(a,s,i,n={}){var l,r;const{clone:o=!1,passive:c=!1,eventName:L,deep:b=!1,defaultValue:N,shouldEmit:g}=n,e=P(),_=i||e?.emit||(e==null||(l=e.$emit)===null||l===void 0?void 0:l.bind(e))||(e==null||(r=e.proxy)===null||r===void 0||(r=r.$emit)===null||r===void 0?void 0:r.bind(e?.proxy));let u=L;u=u||`update:${s.toString()}`;const x=t=>o?typeof o=="function"?o(t):j(t):t,y=()=>U(a[s])?x(a[s]):N,w=t=>{g?g(t)&&_(u,t):_(u,t)};if(c){const t=q(y());let p=!1;return h(()=>a[s],f=>{p||(p=!0,t.value=x(f),M(()=>p=!1))}),h(t,f=>{!p&&(f!==a[s]||b)&&w(f)},{deep:b}),t}else return E({get(){return y()},set(t){w(t)}})}const Q=m({__name:"Input",props:{defaultValue:{},modelValue:{},class:{}},emits:["update:modelValue"],setup(a,{emit:s}){const i=a,l=A(i,"modelValue",s,{passive:!0,defaultValue:i.defaultValue});return(r,o)=>D((v(),I("input",{"onUpdate:modelValue":o[0]||(o[0]=c=>k(l)?l.value=c:null),"data-slot":"input",class:J(d(S)("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm","focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]","aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",i.class))},null,2)),[[F,d(l)]])}}),W=m({__name:"Label",props:{for:{},asChild:{type:Boolean},as:{},class:{}},setup(a){const s=a,i=z(s,"class");return(n,l)=>(v(),V(d(R),C({"data-slot":"label"},d(i),{class:d(S)("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",s.class)}),{default:$(()=>[B(n.$slots,"default")]),_:3},16,["class"]))}});export{W as _,Q as a};

View File

@@ -0,0 +1 @@
import{d as V,k as g,r as i,a as c,w as t,u as a,l as $,m as k,o as p,b as s,e as o,f as m,g as C,_ as L,i as N,t as S,j as f}from"./index-B0FmaMuw.js";import{_ as B,a as R,b as U,c as h}from"./CardTitle.vue_vue_type_script_setup_true_lang-D0guZCre.js";import{_ as j}from"./CardDescription.vue_vue_type_script_setup_true_lang-BYlDBycT.js";import{_,a as v}from"./Label.vue_vue_type_script_setup_true_lang-duvmWwej.js";import{L as q}from"./index-DwEwynZa.js";const A={class:"space-y-2"},D={class:"space-y-2"},G=V({__name:"LoginPage",setup(M){const w=$(),b=k(),x=g(),r=i(""),u=i(""),l=i(!1);async function y(){if(!r.value.trim()||!u.value.trim()){f.error("请输入账号和密码");return}l.value=!0;const d=await x.login({username:r.value.trim(),password:u.value});if(l.value=!1,d.success){f.success("登录成功");const e=b.query.redirect;w.push(e||"/admin/dashboard")}else f.error(d.message||"登录失败")}return(d,e)=>(p(),c(a(B),{class:"w-full max-w-md mx-4"},{default:t(()=>[s(a(R),{class:"text-center"},{default:t(()=>[s(a(U),{class:"text-2xl"},{default:t(()=>[...e[2]||(e[2]=[o("管理后台登录",-1)])]),_:1}),s(a(j),null,{default:t(()=>[...e[3]||(e[3]=[o("请输入您的账号和密码",-1)])]),_:1})]),_:1}),s(a(h),null,{default:t(()=>[m("form",{onSubmit:C(y,["prevent"]),class:"space-y-4"},[m("div",A,[s(a(_),{for:"username"},{default:t(()=>[...e[4]||(e[4]=[o("账号",-1)])]),_:1}),s(a(v),{id:"username",modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=n=>r.value=n),type:"text",placeholder:"请输入账号",disabled:l.value,autocomplete:"username"},null,8,["modelValue","disabled"])]),m("div",D,[s(a(_),{for:"password"},{default:t(()=>[...e[5]||(e[5]=[o("密码",-1)])]),_:1}),s(a(v),{id:"password",modelValue:u.value,"onUpdate:modelValue":e[1]||(e[1]=n=>u.value=n),type:"password",placeholder:"请输入密码",disabled:l.value,autocomplete:"current-password"},null,8,["modelValue","disabled"])]),s(a(L),{type:"submit",class:"w-full",disabled:l.value},{default:t(()=>[l.value?(p(),c(a(q),{key:0,class:"mr-2 h-4 w-4 animate-spin"})):N("",!0),o(" "+S(l.value?"登录中...":"登录"),1)]),_:1},8,["disabled"])],32)]),_:1})]),_:1}))}});export{G as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{d as a,h as n,n as o,u as t,v as r,o as l}from"./index-B0FmaMuw.js";const u=a({__name:"Skeleton",props:{class:{}},setup(s){const e=s;return(c,p)=>(l(),n("div",{"data-slot":"skeleton",class:o(t(r)("animate-pulse rounded-md bg-primary/10",e.class))},null,2))}});export{u as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{y as s,z as p,r as o,q as r}from"./index-B0FmaMuw.js";function m(e){const a=new URLSearchParams().toString();return s.get(`/api/accounts${a?`?${a}`:""}`)}function A(e){return s.post("/api/accounts/create",e)}function b(e){return s.post(`/api/accounts/refresh?id=${e}`)}function w(e){return s.delete(`/api/accounts/delete?id=${e}`)}function y(e){return s.delete("/api/accounts/batch/delete",{data:{ids:e}})}function S(e){return s.post("/api/accounts/batch/refresh",{ids:e})}const _=p("accounts",()=>{const e=o([]),n=o(!1),a=o(null),u=r(()=>e.value.length),i=r(()=>e.value.filter(t=>t.is_active).length),l=r(()=>e.value.filter(t=>!t.is_active).length),d=r(()=>e.value.reduce((t,c)=>t+Math.max(0,(c.seats_entitled||0)-(c.seats_in_use||0)),0));async function f(){n.value=!0,a.value=null;try{const t=await m();if(t.data.success)e.value=t.data.data||[];else throw new Error(t.data.message||"获取账号列表失败")}catch(t){throw a.value=t.message||"获取账号列表失败",t}finally{n.value=!1}}function h(t){const c=e.value.findIndex(v=>v.id===t.id);c!==-1&&(e.value[c]=t)}return{accounts:e,loading:n,error:a,totalTeams:u,validTeams:i,invalidTeams:l,totalAvailableSeats:d,fetchAccounts:f,updateAccount:h}});export{S as a,y as b,A as c,w as d,b as r,_ as u};

View File

@@ -0,0 +1 @@
import{c}from"./index-B0FmaMuw.js";const r=c("circle-x",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m15 9-6 6",key:"1uzhvr"}],["path",{d:"m9 9 6 6",key:"z0biqf"}]]);export{r as C};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{y as t}from"./index-B0FmaMuw.js";function e(i){return t.post("/api/invite/card",i)}function a(i){return t.get(`/api/invite?account_id=${i}`)}function r(i){return t.delete("/api/invite",{data:i})}function o(i){return t.post("/api/invite",i)}export{o as a,r as d,e as i,a as l};

View File

@@ -0,0 +1 @@
import{c as e}from"./index-B0FmaMuw.js";const t=e("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);export{t as R};

14
backend/internal/static/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mygo Team</title>
<script type="module" crossorigin src="/assets/index-B0FmaMuw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_xOPDav.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

1
backend/internal/static/dist/vite.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,56 @@
package static
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed dist/*
var StaticFiles embed.FS
// Handler 返回静态文件处理器
// 用于服务嵌入的前端静态文件
func Handler() http.Handler {
// 提取 dist 子目录
distFS, err := fs.Sub(StaticFiles, "dist")
if err != nil {
// 如果 dist 目录不存在,返回空处理器
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Static files not available", http.StatusNotFound)
})
}
fileServer := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 对于 API 路径,不处理
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// 尝试服务静态文件
// 对于 SPA如果文件不存在返回 index.html
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
// 检查文件是否存在
_, err := fs.Stat(distFS, strings.TrimPrefix(path, "/"))
if err != nil {
// 文件不存在,返回 index.html支持 SPA 路由)
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
// IsAvailable 检查静态文件是否可用
func IsAvailable() bool {
_, err := fs.Sub(StaticFiles, "dist")
return err == nil
}

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
app:
container_name: gpt-manager
build: ./backend
ports:
- "${EXPOSE_PORT}:8080"
depends_on:
- db
env_file:
- .env
environment:
- PORT=${PORT}
restart: unless-stopped
db:
container_name: gpt-manager-db
image: postgres:16-alpine
restart: always
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

View File

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

View File

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

View File

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

View File

@@ -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 || '获取账号列表失败')
}

View File

@@ -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 || '获取卡密列表失败')

View File

@@ -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,10 +147,12 @@ function goToPage(page: number) {
}
}
function handlePageSizeChange(value: string) {
function handlePageSizeChange(value: any) {
if (value) {
pageSize.value = Number(value)
currentPage.value = 1
}
}
</script>
<template>
@@ -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>

View File

@@ -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,22 +205,244 @@ 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) {
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>
<template>
<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>
@@ -267,6 +509,7 @@ function handlePageSizeChange(value: string) {
</DialogContent>
</Dialog>
</div>
</div>
<Card class="min-h-[600px] flex flex-col">
<CardHeader>
@@ -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>