Files
kiro.rs/admin-ui/src/components/kam-import-dialog.tsx
2026-03-05 21:45:42 +08:00

492 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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