mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-12 04:07:42 +08:00
添加会话数量显示与清除功能
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码 账号列表点击测试后显示账号的会话数量 设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
This commit is contained in:
@@ -1,121 +1,127 @@
|
||||
import { useI18n } from '../../i18n'
|
||||
import { useAccountsData } from './useAccountsData'
|
||||
import { useAccountActions } from './useAccountActions'
|
||||
import QueueCards from './QueueCards'
|
||||
import ApiKeysPanel from './ApiKeysPanel'
|
||||
import AccountsTable from './AccountsTable'
|
||||
import AddKeyModal from './AddKeyModal'
|
||||
import AddAccountModal from './AddAccountModal'
|
||||
|
||||
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
|
||||
const { t } = useI18n()
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
changePageSize,
|
||||
resolveAccountIdentifier,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
} = useAccountsData({ apiFetch })
|
||||
|
||||
const {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
setNewKey,
|
||||
copiedKey,
|
||||
setCopiedKey,
|
||||
newAccount,
|
||||
setNewAccount,
|
||||
loading,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
} = useAccountActions({
|
||||
apiFetch,
|
||||
t,
|
||||
onMessage,
|
||||
onRefresh,
|
||||
config,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<QueueCards queueStatus={queueStatus} t={t} />
|
||||
|
||||
<ApiKeysPanel
|
||||
t={t}
|
||||
config={config}
|
||||
keysExpanded={keysExpanded}
|
||||
setKeysExpanded={setKeysExpanded}
|
||||
setShowAddKey={setShowAddKey}
|
||||
copiedKey={copiedKey}
|
||||
setCopiedKey={setCopiedKey}
|
||||
onDeleteKey={deleteKey}
|
||||
/>
|
||||
|
||||
<AccountsTable
|
||||
t={t}
|
||||
accounts={accounts}
|
||||
loadingAccounts={loadingAccounts}
|
||||
testing={testing}
|
||||
testingAll={testingAll}
|
||||
batchProgress={batchProgress}
|
||||
totalAccounts={totalAccounts}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalPages={totalPages}
|
||||
resolveAccountIdentifier={resolveAccountIdentifier}
|
||||
onTestAll={testAllAccounts}
|
||||
onShowAddAccount={() => setShowAddAccount(true)}
|
||||
onTestAccount={testAccount}
|
||||
onDeleteAccount={deleteAccount}
|
||||
onPrevPage={() => fetchAccounts(page - 1)}
|
||||
onNextPage={() => fetchAccounts(page + 1)}
|
||||
onPageSizeChange={changePageSize}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
show={showAddKey}
|
||||
t={t}
|
||||
newKey={newKey}
|
||||
setNewKey={setNewKey}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddKey(false)}
|
||||
onAdd={addKey}
|
||||
/>
|
||||
|
||||
<AddAccountModal
|
||||
show={showAddAccount}
|
||||
t={t}
|
||||
newAccount={newAccount}
|
||||
setNewAccount={setNewAccount}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onAdd={addAccount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useI18n } from '../../i18n'
|
||||
import { useAccountsData } from './useAccountsData'
|
||||
import { useAccountActions } from './useAccountActions'
|
||||
import QueueCards from './QueueCards'
|
||||
import ApiKeysPanel from './ApiKeysPanel'
|
||||
import AccountsTable from './AccountsTable'
|
||||
import AddKeyModal from './AddKeyModal'
|
||||
import AddAccountModal from './AddAccountModal'
|
||||
|
||||
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
|
||||
const { t } = useI18n()
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
changePageSize,
|
||||
resolveAccountIdentifier,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
} = useAccountsData({ apiFetch })
|
||||
|
||||
const {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
setNewKey,
|
||||
copiedKey,
|
||||
setCopiedKey,
|
||||
newAccount,
|
||||
setNewAccount,
|
||||
loading,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
deleteAllSessions,
|
||||
} = useAccountActions({
|
||||
apiFetch,
|
||||
t,
|
||||
onMessage,
|
||||
onRefresh,
|
||||
config,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<QueueCards queueStatus={queueStatus} t={t} />
|
||||
|
||||
<ApiKeysPanel
|
||||
t={t}
|
||||
config={config}
|
||||
keysExpanded={keysExpanded}
|
||||
setKeysExpanded={setKeysExpanded}
|
||||
setShowAddKey={setShowAddKey}
|
||||
copiedKey={copiedKey}
|
||||
setCopiedKey={setCopiedKey}
|
||||
onDeleteKey={deleteKey}
|
||||
/>
|
||||
|
||||
<AccountsTable
|
||||
t={t}
|
||||
accounts={accounts}
|
||||
loadingAccounts={loadingAccounts}
|
||||
testing={testing}
|
||||
testingAll={testingAll}
|
||||
batchProgress={batchProgress}
|
||||
sessionCounts={sessionCounts}
|
||||
deletingSessions={deletingSessions}
|
||||
totalAccounts={totalAccounts}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalPages={totalPages}
|
||||
resolveAccountIdentifier={resolveAccountIdentifier}
|
||||
onTestAll={testAllAccounts}
|
||||
onShowAddAccount={() => setShowAddAccount(true)}
|
||||
onTestAccount={testAccount}
|
||||
onDeleteAccount={deleteAccount}
|
||||
onDeleteAllSessions={deleteAllSessions}
|
||||
onPrevPage={() => fetchAccounts(page - 1)}
|
||||
onNextPage={() => fetchAccounts(page + 1)}
|
||||
onPageSizeChange={changePageSize}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
show={showAddKey}
|
||||
t={t}
|
||||
newKey={newKey}
|
||||
setNewKey={setNewKey}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddKey(false)}
|
||||
onAdd={addKey}
|
||||
/>
|
||||
|
||||
<AddAccountModal
|
||||
show={showAddAccount}
|
||||
t={t}
|
||||
newAccount={newAccount}
|
||||
setNewAccount={setNewAccount}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onAdd={addAccount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,191 +1,213 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function AccountsTable({
|
||||
t,
|
||||
accounts,
|
||||
loadingAccounts,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
totalAccounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
resolveAccountIdentifier,
|
||||
onTestAll,
|
||||
onShowAddAccount,
|
||||
onTestAccount,
|
||||
onDeleteAccount,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onPageSizeChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
const copyId = (id) => {
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 1500)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder={t('accountManager.searchPlaceholder')}
|
||||
className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
onClick={onTestAll}
|
||||
disabled={testingAll || totalAccounts === 0}
|
||||
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
||||
>
|
||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||
{t('accountManager.testAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowAddAccount}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('accountManager.addAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testingAll && batchProgress.total > 0 && (
|
||||
<div className="p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
|
||||
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
|
||||
<div
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{batchProgress.results.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{batchProgress.results.map((r, i) => (
|
||||
<div key={i} className={clsx(
|
||||
"text-xs px-2 py-1 rounded border truncate",
|
||||
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{r.success ? '✓' : '✗'} {r.id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{loadingAccounts ? (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((acc, i) => {
|
||||
const id = resolveAccountIdentifier(acc)
|
||||
return (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
"bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
|
||||
onClick={() => copyId(id)}
|
||||
>
|
||||
<span className="truncate">{id || '-'}</span>
|
||||
{copiedId === id
|
||||
? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
|
||||
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? t('actions.testing') : t('actions.test')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAccount(id)}
|
||||
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
|
||||
</div>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => onPageSizeChange(Number(e.target.value))}
|
||||
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
|
||||
>
|
||||
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrevPage}
|
||||
disabled={page <= 1 || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={page >= totalPages || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function AccountsTable({
|
||||
t,
|
||||
accounts,
|
||||
loadingAccounts,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
totalAccounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
resolveAccountIdentifier,
|
||||
onTestAll,
|
||||
onShowAddAccount,
|
||||
onTestAccount,
|
||||
onDeleteAccount,
|
||||
onDeleteAllSessions,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onPageSizeChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
const copyId = (id) => {
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 1500)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder={t('accountManager.searchPlaceholder')}
|
||||
className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
onClick={onTestAll}
|
||||
disabled={testingAll || totalAccounts === 0}
|
||||
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
||||
>
|
||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||
{t('accountManager.testAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowAddAccount}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('accountManager.addAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testingAll && batchProgress.total > 0 && (
|
||||
<div className="p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
|
||||
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
|
||||
<div
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{batchProgress.results.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{batchProgress.results.map((r, i) => (
|
||||
<div key={i} className={clsx(
|
||||
"text-xs px-2 py-1 rounded border truncate",
|
||||
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{r.success ? '✓' : '✗'} {r.id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{loadingAccounts ? (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((acc, i) => {
|
||||
const id = resolveAccountIdentifier(acc)
|
||||
return (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
"bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
|
||||
onClick={() => copyId(id)}
|
||||
>
|
||||
<span className="truncate">{id || '-'}</span>
|
||||
{copiedId === id
|
||||
? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
|
||||
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
{sessionCounts && sessionCounts[id] !== undefined && (
|
||||
<span className="font-mono bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded text-[10px]">
|
||||
{t('accountManager.sessionCount', { count: sessionCounts[id] })}
|
||||
</span>
|
||||
)}
|
||||
{sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
|
||||
<button
|
||||
onClick={() => onDeleteAllSessions(id)}
|
||||
disabled={deletingSessions && deletingSessions[id]}
|
||||
className="flex items-center gap-1 font-mono bg-red-500/10 text-red-500 hover:bg-red-500/20 px-1.5 py-0.5 rounded text-[10px] transition-colors disabled:opacity-50"
|
||||
title={t('accountManager.deleteAllSessions')}
|
||||
>
|
||||
{deletingSessions && deletingSessions[id] ? (
|
||||
<span className="animate-spin">⟳</span>
|
||||
) : (
|
||||
<FolderX className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? t('actions.testing') : t('actions.test')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAccount(id)}
|
||||
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
|
||||
</div>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => onPageSizeChange(Number(e.target.value))}
|
||||
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
|
||||
>
|
||||
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrevPage}
|
||||
disabled={page <= 1 || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={page >= totalPages || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
const [testing, setTesting] = useState({})
|
||||
const [testingAll, setTestingAll] = useState(false)
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
||||
const [sessionCounts, setSessionCounts] = useState({})
|
||||
const [deletingSessions, setDeletingSessions] = useState({})
|
||||
|
||||
const addKey = async () => {
|
||||
if (!newKey.trim()) return
|
||||
@@ -115,6 +117,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
body: JSON.stringify({ identifier: accountID }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
// 更新会话数
|
||||
if (data.session_count !== undefined) {
|
||||
setSessionCounts(prev => ({ ...prev, [accountID]: data.session_count }))
|
||||
}
|
||||
|
||||
const statusMessage = data.success
|
||||
? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
|
||||
: `${accountID}: ${data.message}`
|
||||
@@ -170,6 +178,41 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
setTestingAll(false)
|
||||
}
|
||||
|
||||
const deleteAllSessions = async (identifier) => {
|
||||
const accountID = String(identifier || '').trim()
|
||||
if (!accountID) {
|
||||
onMessage('error', t('accountManager.invalidIdentifier'))
|
||||
return
|
||||
}
|
||||
if (!confirm(t('accountManager.deleteAllSessionsConfirm'))) return
|
||||
|
||||
setDeletingSessions(prev => ({ ...prev, [accountID]: true }))
|
||||
try {
|
||||
const res = await apiFetch('/admin/accounts/sessions/delete-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier: accountID }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
onMessage('success', t('accountManager.deleteAllSessionsSuccess', { count: data.deleted }))
|
||||
// 清除会话数显示
|
||||
setSessionCounts(prev => {
|
||||
const newCounts = { ...prev }
|
||||
delete newCounts[accountID]
|
||||
return newCounts
|
||||
})
|
||||
} else {
|
||||
onMessage('error', data.message || t('messages.requestFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', t('messages.networkError'))
|
||||
} finally {
|
||||
setDeletingSessions(prev => ({ ...prev, [accountID]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
@@ -185,11 +228,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
deleteAllSessions,
|
||||
}
|
||||
}
|
||||
|
||||
39
webui/src/features/settings/AutoDeleteSection.jsx
Normal file
39
webui/src/features/settings/AutoDeleteSection.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
export default function AutoDeleteSection({ t, form, setForm }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold">{t('settings.autoDeleteTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.autoDeleteDesc')}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{t('settings.autoDeleteSessions')}</label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={form.auto_delete?.sessions || false}
|
||||
onClick={() => setForm((prev) => ({
|
||||
...prev,
|
||||
auto_delete: { ...prev.auto_delete, sessions: !prev.auto_delete?.sessions },
|
||||
}))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
form.auto_delete?.sessions ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
form.auto_delete?.sessions ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{form.auto_delete?.sessions && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||
{t('settings.autoDeleteWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
|
||||
import SecuritySection from './SecuritySection'
|
||||
import RuntimeSection from './RuntimeSection'
|
||||
import BehaviorSection from './BehaviorSection'
|
||||
import AutoDeleteSection from './AutoDeleteSection'
|
||||
import ModelSection from './ModelSection'
|
||||
import BackupSection from './BackupSection'
|
||||
|
||||
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
|
||||
|
||||
<BehaviorSection t={t} form={form} setForm={setForm} />
|
||||
|
||||
<AutoDeleteSection t={t} form={form} setForm={setForm} />
|
||||
|
||||
<ModelSection t={t} form={form} setForm={setForm} />
|
||||
|
||||
<BackupSection
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_FORM = {
|
||||
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
|
||||
responses: { store_ttl_seconds: 900 },
|
||||
embeddings: { provider: '' },
|
||||
auto_delete: { sessions: false },
|
||||
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
|
||||
model_aliases_text: '{}',
|
||||
}
|
||||
@@ -55,6 +56,9 @@ function fromServerForm(data) {
|
||||
embeddings: {
|
||||
provider: data.embeddings?.provider || '',
|
||||
},
|
||||
auto_delete: {
|
||||
sessions: Boolean(data.auto_delete?.sessions || false),
|
||||
},
|
||||
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
|
||||
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
|
||||
}
|
||||
@@ -74,6 +78,7 @@ function toServerPayload(form) {
|
||||
},
|
||||
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
|
||||
embeddings: { provider: String(form.embeddings.provider || '').trim() },
|
||||
auto_delete: { sessions: Boolean(form.auto_delete?.sessions) },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user