🎨 优化项目启动逻辑

This commit is contained in:
2026-03-05 21:58:45 +08:00
parent d0cabadcec
commit 5e589369b6
6 changed files with 16 additions and 931 deletions

3
.gitignore vendored
View File

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

@@ -911,7 +911,7 @@ dependencies = [
[[package]]
name = "kiro-rs"
version = "1.1.5"
version = "1.1.4"
dependencies = [
"anyhow",
"axum",

View File

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

View File

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

View File

@@ -11,4 +11,4 @@ services:
- "8990:8990"
volumes:
- ./config/:/app/config/
restart: unless-stopped
restart: unless-stopped

View File

@@ -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());