🎨 优化项目启动逻辑
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,6 +7,9 @@
|
||||
|
||||
/admin-ui/node_modules/
|
||||
/admin-ui/dist/
|
||||
/admin-ui/pnpm-lock.yaml
|
||||
/admin-ui/package-lock.json
|
||||
/admin-ui/tsconfig.tsbuildinfo
|
||||
|
||||
.claude/
|
||||
!.claude/skills/
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -911,7 +911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kiro-rs"
|
||||
version = "1.1.5"
|
||||
version = "1.1.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
|
||||
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
|
||||
|
||||
interface BatchImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface CredentialInput {
|
||||
refreshToken: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
region?: string
|
||||
authRegion?: string
|
||||
apiRegion?: string
|
||||
priority?: number
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
index: number
|
||||
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed'
|
||||
error?: string
|
||||
usage?: string
|
||||
email?: string
|
||||
credentialId?: number
|
||||
rollbackStatus?: 'success' | 'failed' | 'skipped'
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function BatchImportDialog({ open, onOpenChange }: BatchImportDialogProps) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [currentProcessing, setCurrentProcessing] = useState<string>('')
|
||||
const [results, setResults] = useState<VerificationResult[]>([])
|
||||
|
||||
const { data: existingCredentials } = useCredentials()
|
||||
const { mutateAsync: addCredential } = useAddCredential()
|
||||
const { mutateAsync: deleteCredential } = useDeleteCredential()
|
||||
|
||||
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await setCredentialDisabled(id, true)
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `禁用失败: ${extractErrorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCredential(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `删除失败: ${extractErrorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonInput('')
|
||||
setProgress({ current: 0, total: 0 })
|
||||
setCurrentProcessing('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
// 先单独解析 JSON,给出精准的错误提示
|
||||
let credentials: CredentialInput[]
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput)
|
||||
credentials = Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch (error) {
|
||||
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
toast.error('没有可导入的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setImporting(true)
|
||||
setProgress({ current: 0, total: credentials.length })
|
||||
|
||||
// 2. 初始化结果
|
||||
const initialResults: VerificationResult[] = credentials.map((_, i) => ({
|
||||
index: i + 1,
|
||||
status: 'pending'
|
||||
}))
|
||||
setResults(initialResults)
|
||||
|
||||
// 3. 检测重复
|
||||
const existingTokenHashes = new Set(
|
||||
existingCredentials?.credentials
|
||||
.map(c => c.refreshTokenHash)
|
||||
.filter((hash): hash is string => Boolean(hash)) || []
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let duplicateCount = 0
|
||||
let failCount = 0
|
||||
let rollbackSuccessCount = 0
|
||||
let rollbackFailedCount = 0
|
||||
let rollbackSkippedCount = 0
|
||||
|
||||
// 4. 导入并验活
|
||||
for (let i = 0; i < credentials.length; i++) {
|
||||
const cred = credentials[i]
|
||||
const token = cred.refreshToken.trim()
|
||||
const tokenHash = await sha256Hex(token)
|
||||
|
||||
// 更新状态为检查中
|
||||
setCurrentProcessing(`正在处理凭据 ${i + 1}/${credentials.length}`)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = { ...newResults[i], status: 'checking' }
|
||||
return newResults
|
||||
})
|
||||
|
||||
// 检查重复
|
||||
if (existingTokenHashes.has(tokenHash)) {
|
||||
duplicateCount++
|
||||
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'duplicate',
|
||||
error: '该凭据已存在',
|
||||
email: existingCred?.email || undefined
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
setProgress({ current: i + 1, total: credentials.length })
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新状态为验活中
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = { ...newResults[i], status: 'verifying' }
|
||||
return newResults
|
||||
})
|
||||
|
||||
let addedCredId: number | null = null
|
||||
|
||||
try {
|
||||
// 添加凭据
|
||||
const clientId = cred.clientId?.trim() || undefined
|
||||
const clientSecret = cred.clientSecret?.trim() || undefined
|
||||
const authMethod = clientId && clientSecret ? 'idc' : 'social'
|
||||
|
||||
// idc 模式下必须同时提供 clientId 和 clientSecret
|
||||
if (authMethod === 'social' && (clientId || clientSecret)) {
|
||||
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
|
||||
}
|
||||
|
||||
const addedCred = await addCredential({
|
||||
refreshToken: token,
|
||||
authMethod,
|
||||
region: cred.authRegion?.trim() || cred.region?.trim() || undefined,
|
||||
apiRegion: cred.apiRegion?.trim() || undefined,
|
||||
clientId,
|
||||
clientSecret,
|
||||
priority: cred.priority || 0,
|
||||
machineId: cred.machineId?.trim() || undefined,
|
||||
})
|
||||
|
||||
addedCredId = addedCred.credentialId
|
||||
|
||||
// 延迟 1 秒
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 验活
|
||||
const balance = await getCredentialBalance(addedCred.credentialId)
|
||||
|
||||
// 验活成功
|
||||
successCount++
|
||||
existingTokenHashes.add(tokenHash)
|
||||
setCurrentProcessing(addedCred.email ? `验活成功: ${addedCred.email}` : `验活成功: 凭据 ${i + 1}`)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'verified',
|
||||
usage: `${balance.currentUsage}/${balance.usageLimit}`,
|
||||
email: addedCred.email || undefined,
|
||||
credentialId: addedCred.credentialId
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
} catch (error) {
|
||||
// 验活失败,尝试回滚(先禁用再删除)
|
||||
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
|
||||
let rollbackError: string | undefined
|
||||
|
||||
if (addedCredId) {
|
||||
const rollbackResult = await rollbackCredential(addedCredId)
|
||||
if (rollbackResult.success) {
|
||||
rollbackStatus = 'success'
|
||||
rollbackSuccessCount++
|
||||
} else {
|
||||
rollbackStatus = 'failed'
|
||||
rollbackFailedCount++
|
||||
rollbackError = rollbackResult.error
|
||||
}
|
||||
} else {
|
||||
rollbackSkippedCount++
|
||||
}
|
||||
|
||||
failCount++
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'failed',
|
||||
error: extractErrorMessage(error),
|
||||
email: undefined,
|
||||
rollbackStatus,
|
||||
rollbackError,
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
}
|
||||
|
||||
setProgress({ current: i + 1, total: credentials.length })
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
if (failCount === 0 && duplicateCount === 0) {
|
||||
toast.success(`成功导入并验活 ${successCount} 个凭据`)
|
||||
} else {
|
||||
const failureSummary = failCount > 0
|
||||
? `,失败 ${failCount} 个(已排除 ${rollbackSuccessCount},未排除 ${rollbackFailedCount},无需排除 ${rollbackSkippedCount})`
|
||||
: ''
|
||||
toast.info(`验活完成:成功 ${successCount} 个,重复 ${duplicateCount} 个${failureSummary}`)
|
||||
|
||||
if (rollbackFailedCount > 0) {
|
||||
toast.warning(`有 ${rollbackFailedCount} 个失败凭据回滚未完成,请手动禁用并删除`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('导入失败: ' + extractErrorMessage(error))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: VerificationResult['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
|
||||
case 'checking':
|
||||
case 'verifying':
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||
case 'verified':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
case 'duplicate':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (result: VerificationResult) => {
|
||||
switch (result.status) {
|
||||
case 'pending':
|
||||
return '等待中'
|
||||
case 'checking':
|
||||
return '检查重复...'
|
||||
case 'verifying':
|
||||
return '验活中...'
|
||||
case 'verified':
|
||||
return '验活成功'
|
||||
case 'duplicate':
|
||||
return '重复凭据'
|
||||
case 'failed':
|
||||
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
|
||||
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
|
||||
return '验活失败(未创建)'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// 关闭时清空表单(但不在导入过程中清空)
|
||||
if (!newOpen && !importing) {
|
||||
resetForm()
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入凭据(自动验活)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
JSON 格式凭据
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={'粘贴 JSON 格式的凭据(支持单个对象或数组)\n例如: [{"refreshToken":"...","clientId":"...","clientSecret":"...","authRegion":"us-east-1","apiRegion":"us-west-2"}]\n支持 region 字段自动映射为 authRegion'}
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
disabled={importing}
|
||||
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 导入时自动验活,失败的凭据会被排除
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(importing || results.length > 0) && (
|
||||
<>
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{importing ? '验活进度' : '验活完成'}</span>
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importing && currentProcessing && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{currentProcessing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✓ 成功: {results.filter(r => r.status === 'verified').length}
|
||||
</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠ 重复: {results.filter(r => r.status === 'duplicate').length}
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
✗ 失败: {results.filter(r => r.status === 'failed').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div key={result.index} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(result.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{result.email || `凭据 #${result.index}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStatusText(result)}
|
||||
</span>
|
||||
</div>
|
||||
{result.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
用量: {result.usage}
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.rollbackError && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
回滚失败: {result.rollbackError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? '验活中...' : results.length > 0 ? '关闭' : '取消'}
|
||||
</Button>
|
||||
{results.length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleBatchImport}
|
||||
disabled={importing || !jsonInput.trim()}
|
||||
>
|
||||
开始导入并验活
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
|
||||
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
|
||||
|
||||
interface KamImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// KAM 导出 JSON 中的账号结构
|
||||
interface KamAccount {
|
||||
email?: string
|
||||
userId?: string | null
|
||||
nickname?: string
|
||||
credentials: {
|
||||
refreshToken: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
region?: string
|
||||
authMethod?: string
|
||||
startUrl?: string
|
||||
}
|
||||
machineId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
index: number
|
||||
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed' | 'skipped'
|
||||
error?: string
|
||||
usage?: string
|
||||
email?: string
|
||||
credentialId?: number
|
||||
rollbackStatus?: 'success' | 'failed' | 'skipped'
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 校验元素是否为有效的 KAM 账号结构
|
||||
function isValidKamAccount(item: unknown): item is KamAccount {
|
||||
if (typeof item !== 'object' || item === null) return false
|
||||
const obj = item as Record<string, unknown>
|
||||
if (typeof obj.credentials !== 'object' || obj.credentials === null) return false
|
||||
const cred = obj.credentials as Record<string, unknown>
|
||||
return typeof cred.refreshToken === 'string' && cred.refreshToken.trim().length > 0
|
||||
}
|
||||
|
||||
// 解析 KAM 导出 JSON,支持单账号和多账号格式
|
||||
function parseKamJson(raw: string): KamAccount[] {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
let rawItems: unknown[]
|
||||
|
||||
// 标准 KAM 导出格式:{ version, accounts: [...] }
|
||||
if (parsed.accounts && Array.isArray(parsed.accounts)) {
|
||||
rawItems = parsed.accounts
|
||||
}
|
||||
// 兜底:如果直接是账号数组
|
||||
else if (Array.isArray(parsed)) {
|
||||
rawItems = parsed
|
||||
}
|
||||
// 单个账号对象(有 credentials 字段)
|
||||
else if (parsed.credentials && typeof parsed.credentials === 'object') {
|
||||
rawItems = [parsed]
|
||||
}
|
||||
else {
|
||||
throw new Error('无法识别的 KAM JSON 格式')
|
||||
}
|
||||
|
||||
const validAccounts = rawItems.filter(isValidKamAccount)
|
||||
|
||||
if (rawItems.length > 0 && validAccounts.length === 0) {
|
||||
throw new Error(`共 ${rawItems.length} 条记录,但均缺少有效的 credentials.refreshToken`)
|
||||
}
|
||||
|
||||
if (validAccounts.length < rawItems.length) {
|
||||
const skipped = rawItems.length - validAccounts.length
|
||||
console.warn(`KAM 导入:跳过 ${skipped} 条缺少有效 credentials.refreshToken 的记录`)
|
||||
}
|
||||
|
||||
return validAccounts
|
||||
}
|
||||
|
||||
export function KamImportDialog({ open, onOpenChange }: KamImportDialogProps) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [skipErrorAccounts, setSkipErrorAccounts] = useState(true)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [currentProcessing, setCurrentProcessing] = useState<string>('')
|
||||
const [results, setResults] = useState<VerificationResult[]>([])
|
||||
|
||||
const { data: existingCredentials } = useCredentials()
|
||||
const { mutateAsync: addCredential } = useAddCredential()
|
||||
const { mutateAsync: deleteCredential } = useDeleteCredential()
|
||||
|
||||
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await setCredentialDisabled(id, true)
|
||||
} catch (error) {
|
||||
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
try {
|
||||
await deleteCredential(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonInput('')
|
||||
setProgress({ current: 0, total: 0 })
|
||||
setCurrentProcessing('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
// 先单独解析 JSON,给出精准的错误提示
|
||||
let validAccounts: KamAccount[]
|
||||
try {
|
||||
const accounts = parseKamJson(jsonInput)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
toast.error('没有可导入的账号')
|
||||
return
|
||||
}
|
||||
|
||||
validAccounts = accounts.filter(a => a.credentials?.refreshToken)
|
||||
if (validAccounts.length === 0) {
|
||||
toast.error('没有包含有效 refreshToken 的账号')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
setImporting(true)
|
||||
setProgress({ current: 0, total: validAccounts.length })
|
||||
|
||||
// 初始化结果,标记 error 状态的账号
|
||||
const initialResults: VerificationResult[] = validAccounts.map((account, i) => {
|
||||
if (skipErrorAccounts && account.status === 'error') {
|
||||
return { index: i + 1, status: 'skipped' as const, email: account.email || account.nickname }
|
||||
}
|
||||
return { index: i + 1, status: 'pending' as const, email: account.email || account.nickname }
|
||||
})
|
||||
setResults(initialResults)
|
||||
|
||||
// 重复检测
|
||||
const existingTokenHashes = new Set(
|
||||
existingCredentials?.credentials
|
||||
.map(c => c.refreshTokenHash)
|
||||
.filter((hash): hash is string => Boolean(hash)) || []
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let duplicateCount = 0
|
||||
let failCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (let i = 0; i < validAccounts.length; i++) {
|
||||
const account = validAccounts[i]
|
||||
|
||||
// 跳过 error 状态的账号
|
||||
if (skipErrorAccounts && account.status === 'error') {
|
||||
skippedCount++
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
continue
|
||||
}
|
||||
|
||||
const cred = account.credentials
|
||||
const token = cred.refreshToken.trim()
|
||||
const tokenHash = await sha256Hex(token)
|
||||
|
||||
setCurrentProcessing(`正在处理 ${account.email || account.nickname || `账号 ${i + 1}`}`)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'checking' }
|
||||
return next
|
||||
})
|
||||
|
||||
// 检查重复
|
||||
if (existingTokenHashes.has(tokenHash)) {
|
||||
duplicateCount++
|
||||
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'duplicate', error: '该凭据已存在', email: existingCred?.email || account.email }
|
||||
return next
|
||||
})
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
continue
|
||||
}
|
||||
|
||||
// 验活中
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'verifying' }
|
||||
return next
|
||||
})
|
||||
|
||||
let addedCredId: number | null = null
|
||||
|
||||
try {
|
||||
const clientId = cred.clientId?.trim() || undefined
|
||||
const clientSecret = cred.clientSecret?.trim() || undefined
|
||||
const authMethod = clientId && clientSecret ? 'idc' : 'social'
|
||||
|
||||
// idc 模式下必须同时提供 clientId 和 clientSecret
|
||||
if (authMethod === 'social' && (clientId || clientSecret)) {
|
||||
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
|
||||
}
|
||||
|
||||
const addedCred = await addCredential({
|
||||
refreshToken: token,
|
||||
authMethod,
|
||||
region: cred.region?.trim() || undefined,
|
||||
clientId,
|
||||
clientSecret,
|
||||
machineId: account.machineId?.trim() || undefined,
|
||||
})
|
||||
|
||||
addedCredId = addedCred.credentialId
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const balance = await getCredentialBalance(addedCred.credentialId)
|
||||
|
||||
successCount++
|
||||
existingTokenHashes.add(tokenHash)
|
||||
setCurrentProcessing(`验活成功: ${addedCred.email || account.email || `账号 ${i + 1}`}`)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = {
|
||||
...next[i],
|
||||
status: 'verified',
|
||||
usage: `${balance.currentUsage}/${balance.usageLimit}`,
|
||||
email: addedCred.email || account.email,
|
||||
credentialId: addedCred.credentialId,
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
|
||||
let rollbackError: string | undefined
|
||||
|
||||
if (addedCredId) {
|
||||
const result = await rollbackCredential(addedCredId)
|
||||
if (result.success) {
|
||||
rollbackStatus = 'success'
|
||||
} else {
|
||||
rollbackStatus = 'failed'
|
||||
rollbackError = result.error
|
||||
}
|
||||
}
|
||||
|
||||
failCount++
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = {
|
||||
...next[i],
|
||||
status: 'failed',
|
||||
error: extractErrorMessage(error),
|
||||
rollbackStatus,
|
||||
rollbackError,
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
}
|
||||
|
||||
// 汇总
|
||||
const parts: string[] = []
|
||||
if (successCount > 0) parts.push(`成功 ${successCount}`)
|
||||
if (duplicateCount > 0) parts.push(`重复 ${duplicateCount}`)
|
||||
if (failCount > 0) parts.push(`失败 ${failCount}`)
|
||||
if (skippedCount > 0) parts.push(`跳过 ${skippedCount}`)
|
||||
|
||||
if (failCount === 0 && duplicateCount === 0 && skippedCount === 0) {
|
||||
toast.success(`成功导入并验活 ${successCount} 个凭据`)
|
||||
} else {
|
||||
toast.info(`导入完成:${parts.join(',')}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('导入失败: ' + extractErrorMessage(error))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: VerificationResult['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
|
||||
case 'checking':
|
||||
case 'verifying':
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||
case 'verified':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
case 'duplicate':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
case 'skipped':
|
||||
return <AlertCircle className="w-5 h-5 text-gray-400" />
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (result: VerificationResult) => {
|
||||
switch (result.status) {
|
||||
case 'pending': return '等待中'
|
||||
case 'checking': return '检查重复...'
|
||||
case 'verifying': return '验活中...'
|
||||
case 'verified': return '验活成功'
|
||||
case 'duplicate': return '重复凭据'
|
||||
case 'skipped': return '已跳过(error 状态)'
|
||||
case 'failed':
|
||||
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
|
||||
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
|
||||
return '验活失败(未创建)'
|
||||
}
|
||||
}
|
||||
|
||||
// 预览解析结果
|
||||
const { previewAccounts, parseError } = useMemo(() => {
|
||||
if (!jsonInput.trim()) return { previewAccounts: [] as KamAccount[], parseError: '' }
|
||||
try {
|
||||
return { previewAccounts: parseKamJson(jsonInput), parseError: '' }
|
||||
} catch (e) {
|
||||
return { previewAccounts: [] as KamAccount[], parseError: extractErrorMessage(e) }
|
||||
}
|
||||
}, [jsonInput])
|
||||
|
||||
const errorAccountCount = previewAccounts.filter(a => a.status === 'error').length
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && importing) return
|
||||
if (!newOpen) resetForm()
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>KAM 账号导入(自动验活)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">KAM 导出 JSON</label>
|
||||
<textarea
|
||||
placeholder={'粘贴 Kiro Account Manager 导出的 JSON,格式如下:\n{\n "version": "1.5.0",\n "accounts": [\n {\n "email": "...",\n "credentials": {\n "refreshToken": "...",\n "clientId": "...",\n "clientSecret": "...",\n "region": "us-east-1"\n }\n }\n ]\n}'}
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
disabled={importing}
|
||||
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 解析预览 */}
|
||||
{parseError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">解析失败: {parseError}</div>
|
||||
)}
|
||||
{previewAccounts.length > 0 && !importing && results.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
识别到 {previewAccounts.length} 个账号
|
||||
{errorAccountCount > 0 && `(其中 ${errorAccountCount} 个为 error 状态)`}
|
||||
</div>
|
||||
{errorAccountCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipErrorAccounts}
|
||||
onChange={(e) => setSkipErrorAccounts(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
跳过 error 状态的账号
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入进度和结果 */}
|
||||
{(importing || results.length > 0) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{importing ? '导入进度' : '导入完成'}</span>
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importing && currentProcessing && (
|
||||
<div className="text-xs text-muted-foreground">{currentProcessing}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✓ 成功: {results.filter(r => r.status === 'verified').length}
|
||||
</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠ 重复: {results.filter(r => r.status === 'duplicate').length}
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
✗ 失败: {results.filter(r => r.status === 'failed').length}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
○ 跳过: {results.filter(r => r.status === 'skipped').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div key={result.index} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(result.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{result.email || `账号 #${result.index}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStatusText(result)}
|
||||
</span>
|
||||
</div>
|
||||
{result.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-1">用量: {result.usage}</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">{result.error}</div>
|
||||
)}
|
||||
{result.rollbackError && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">回滚失败: {result.rollbackError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { onOpenChange(false); resetForm() }}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? '导入中...' : results.length > 0 ? '关闭' : '取消'}
|
||||
</Button>
|
||||
{results.length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonInput.trim() || previewAccounts.length === 0 || !!parseError}
|
||||
>
|
||||
开始导入并验活
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -11,4 +11,4 @@ services:
|
||||
- "8990:8990"
|
||||
volumes:
|
||||
- ./config/:/app/config/
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
15
src/main.rs
15
src/main.rs
@@ -102,10 +102,17 @@ async fn main() {
|
||||
});
|
||||
let token_manager = Arc::new(token_manager);
|
||||
|
||||
// 初始化余额缓存并按余额选择初始凭据
|
||||
let init_count = token_manager.initialize_balances().await;
|
||||
if init_count == 0 && token_manager.total_count() > 0 {
|
||||
tracing::warn!("所有凭据余额初始化失败,将按优先级选择凭据");
|
||||
// 在后台异步初始化余额缓存,不阻塞服务器启动
|
||||
// 大量凭据(如 1000 个)每隔 0.5 秒顺序查询,若同步等待会导致服务长时间不可用
|
||||
{
|
||||
let tm = token_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("后台余额初始化任务已启动...");
|
||||
let init_count = tm.initialize_balances().await;
|
||||
if init_count == 0 && tm.total_count() > 0 {
|
||||
tracing::warn!("所有凭据余额初始化失败,将按优先级选择凭据");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let kiro_provider = KiroProvider::with_proxy(token_manager.clone(), proxy_config.clone());
|
||||
|
||||
Reference in New Issue
Block a user