feat: 添加功能和修复问题
- 添加全局 API Token 认证支持 (环境变量 API_TOKEN) - Team 页面添加直接邀请按钮 - Team 页面添加随机邀请按钮 - 修复已邀请用户列表字段名不匹配问题 - 修复数据库为空时错误显示 toast 的问题
This commit is contained in:
@@ -16,3 +16,6 @@ PORT=8080
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# API Token (用于外部 API 调用,可选)
|
||||||
|
API_TOKEN=your-api-token-here
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gpt-manager-go/internal/auth"
|
"gpt-manager-go/internal/auth"
|
||||||
@@ -31,7 +32,21 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
tokenString := parts[1]
|
tokenString := parts[1]
|
||||||
|
|
||||||
// 解析 Token
|
// 首先检查是否是 API Token
|
||||||
|
apiToken := os.Getenv("API_TOKEN")
|
||||||
|
if apiToken != "" && tokenString == apiToken {
|
||||||
|
// API Token 认证成功,创建虚拟管理员上下文
|
||||||
|
claims := &auth.Claims{
|
||||||
|
UserID: 0,
|
||||||
|
Username: "api_token",
|
||||||
|
IsSuperAdmin: true,
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JWT Token
|
||||||
claims, err := auth.ParseToken(tokenString)
|
claims, err := auth.ParseToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
http.Error(w, `{"success":false,"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
||||||
|
|||||||
@@ -37,3 +37,20 @@ export function listInvitations(accountId: number) {
|
|||||||
export function deleteInvite(data: DeleteInviteRequest) {
|
export function deleteInvite(data: DeleteInviteRequest) {
|
||||||
return request.delete('/api/invite', { data })
|
return request.delete('/api/invite', { data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminInviteRequest {
|
||||||
|
email: string
|
||||||
|
account_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminInviteResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
invitation_id?: number
|
||||||
|
account_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inviteByAdmin(data: AdminInviteRequest) {
|
||||||
|
return request.post<AdminInviteResponse>('/api/invite', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await getAccounts()
|
const response = await getAccounts()
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success) {
|
||||||
accounts.value = response.data.data
|
accounts.value = response.data.data || []
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.data.message || '获取账号列表失败')
|
throw new Error(response.data.message || '获取账号列表失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ async function loadCardKeys() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
||||||
if (response.data.success && response.data.keys) {
|
if (response.data.success) {
|
||||||
cardKeys.value = response.data.keys
|
cardKeys.value = response.data.keys || []
|
||||||
total.value = response.data.total || 0
|
total.value = response.data.total || 0
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.data.message || '获取卡密列表失败')
|
toast.error(response.data.message || '获取卡密列表失败')
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import { createAccount, refreshAccount, deleteAccount, type Account } from '@/api/accounts'
|
import { createAccount, refreshAccount, deleteAccount, 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 router = useRouter()
|
||||||
const accountsStore = useAccountsStore()
|
const accountsStore = useAccountsStore()
|
||||||
@@ -67,6 +68,18 @@ const showToken = ref(false)
|
|||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
const deleteDialogOpen = ref(false)
|
const deleteDialogOpen = ref(false)
|
||||||
|
|
||||||
|
// Invite dialog
|
||||||
|
const inviteDialogOpen = ref(false)
|
||||||
|
const invitingAccountId = ref<number | null>(null)
|
||||||
|
const invitingAccountName = ref('')
|
||||||
|
const inviteEmail = ref('')
|
||||||
|
const inviting = ref(false)
|
||||||
|
|
||||||
|
// Random invite dialog
|
||||||
|
const randomInviteDialogOpen = ref(false)
|
||||||
|
const randomInviteEmail = ref('')
|
||||||
|
const randomInviting = ref(false)
|
||||||
const pendingDelete = ref<Account | null>(null)
|
const pendingDelete = ref<Account | null>(null)
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -185,6 +198,74 @@ function viewInvites(account: Account) {
|
|||||||
router.push(`/admin/teams/${account.id}/invites`)
|
router.push(`/admin/teams/${account.id}/invites`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInviteDialog(account: Account) {
|
||||||
|
invitingAccountId.value = account.id
|
||||||
|
invitingAccountName.value = account.name || account.team_account_id
|
||||||
|
inviteEmail.value = ''
|
||||||
|
inviteDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
if (!inviteEmail.value.trim()) {
|
||||||
|
toast.error('请输入邮箱地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!invitingAccountId.value) return
|
||||||
|
|
||||||
|
inviting.value = true
|
||||||
|
try {
|
||||||
|
const response = await inviteByAdmin({
|
||||||
|
email: inviteEmail.value.trim(),
|
||||||
|
account_id: invitingAccountId.value,
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('邀请发送成功')
|
||||||
|
inviteDialogOpen.value = false
|
||||||
|
inviteEmail.value = ''
|
||||||
|
// Refresh account to update seats
|
||||||
|
if (invitingAccountId.value) {
|
||||||
|
await handleRefresh(accountsStore.accounts.find(a => a.id === invitingAccountId.value)!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || '邀请失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '邀请失败')
|
||||||
|
} finally {
|
||||||
|
inviting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random invite - auto select available team
|
||||||
|
async function handleRandomInvite() {
|
||||||
|
if (!randomInviteEmail.value.trim()) {
|
||||||
|
toast.error('请输入邮箱地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
randomInviting.value = true
|
||||||
|
try {
|
||||||
|
// Use account_id = 0 to let backend auto-select
|
||||||
|
const response = await inviteByAdmin({
|
||||||
|
email: randomInviteEmail.value.trim(),
|
||||||
|
account_id: 0,
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(`邀请发送成功,已分配到: ${response.data.account_name || 'Team'}`)
|
||||||
|
randomInviteDialogOpen.value = false
|
||||||
|
randomInviteEmail.value = ''
|
||||||
|
// Refresh all accounts to update seats
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || '邀请失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.response?.data?.message || '邀请失败')
|
||||||
|
} finally {
|
||||||
|
randomInviting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
@@ -203,6 +284,7 @@ function handlePageSizeChange(value: any) {
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Team 管理</h1>
|
<h1 class="text-2xl font-bold">Team 管理</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<Dialog v-model:open="dialogOpen">
|
<Dialog v-model:open="dialogOpen">
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -268,6 +350,43 @@ function handlePageSizeChange(value: any) {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card class="min-h-[600px] flex flex-col">
|
<Card class="min-h-[600px] flex flex-col">
|
||||||
@@ -329,7 +448,10 @@ function handlePageSizeChange(value: any) {
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" @click="viewInvites(account)">
|
<Button variant="outline" size="sm" @click="openInviteDialog(account)" title="直接邀请">
|
||||||
|
<UserPlus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="viewInvites(account)" title="查看已邀请用户">
|
||||||
<Users class="h-4 w-4" />
|
<Users class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -410,5 +532,35 @@ function handlePageSizeChange(value: any) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<!-- Invite dialog -->
|
||||||
|
<Dialog v-model:open="inviteDialogOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>邀请用户</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
邀请用户加入 Team: {{ invitingAccountName }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="handleInvite" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_email">邮箱地址 *</Label>
|
||||||
|
<Input
|
||||||
|
id="invite_email"
|
||||||
|
v-model="inviteEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
:disabled="inviting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" :disabled="inviting">
|
||||||
|
<Loader2 v-if="inviting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{{ inviting ? '邀请中...' : '发送邀请' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user