🎨 优化项目启动逻辑
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
/admin-ui/node_modules/
|
/admin-ui/node_modules/
|
||||||
/admin-ui/dist/
|
/admin-ui/dist/
|
||||||
|
/admin-ui/pnpm-lock.yaml
|
||||||
|
/admin-ui/package-lock.json
|
||||||
|
/admin-ui/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
!.claude/skills/
|
!.claude/skills/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -911,7 +911,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kiro-rs"
|
name = "kiro-rs"
|
||||||
version = "1.1.5"
|
version = "1.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"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"
|
- "8990:8990"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/:/app/config/
|
- ./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 token_manager = Arc::new(token_manager);
|
||||||
|
|
||||||
// 初始化余额缓存并按余额选择初始凭据
|
// 在后台异步初始化余额缓存,不阻塞服务器启动
|
||||||
let init_count = token_manager.initialize_balances().await;
|
// 大量凭据(如 1000 个)每隔 0.5 秒顺序查询,若同步等待会导致服务长时间不可用
|
||||||
if init_count == 0 && token_manager.total_count() > 0 {
|
{
|
||||||
tracing::warn!("所有凭据余额初始化失败,将按优先级选择凭据");
|
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());
|
let kiro_provider = KiroProvider::with_proxy(token_manager.clone(), proxy_config.clone());
|
||||||
|
|||||||
Reference in New Issue
Block a user