feat: 添加功能和修复问题

- 添加全局 API Token 认证支持 (环境变量 API_TOKEN)
- Team 页面添加直接邀请按钮
- Team 页面添加随机邀请按钮
- 修复已邀请用户列表字段名不匹配问题
- 修复数据库为空时错误显示 toast 的问题
This commit is contained in:
sar
2026-01-14 13:25:49 +08:00
parent a0a7640e8a
commit 93aa31219d
6 changed files with 194 additions and 7 deletions

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

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

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

@@ -54,7 +54,8 @@ import {
} 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 { inviteByAdmin } from '@/api/invite'
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2, UserPlus, Shuffle } from 'lucide-vue-next'
const router = useRouter()
const accountsStore = useAccountsStore()
@@ -67,6 +68,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
@@ -185,6 +198,74 @@ 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
@@ -203,6 +284,7 @@ function handlePageSizeChange(value: any) {
<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">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button>
@@ -268,6 +350,43 @@ function handlePageSizeChange(value: any) {
</form>
</DialogContent>
</Dialog>
<!-- 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>
</div>
</div>
<Card class="min-h-[600px] flex flex-col">
@@ -329,7 +448,10 @@ function handlePageSizeChange(value: any) {
]"
/>
</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
@@ -410,5 +532,35 @@ function handlePageSizeChange(value: any) {
</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>