new-api/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
QuentinHsu f69ceb6967
fix: 修复新 UI 语言与文案显示问题 (#4876)
* chore(dev): add local setup state reset target

- add a reset-setup make target to clear setup records, root users, and related options.
- support both docker dev PostgreSQL and local SQLite development databases.
- restart the docker dev backend so setup status is recalculated after reset.

* fix(chat): prevent preset menu text overflow

- add truncation layout for chat preset names to keep long labels inside the sidebar menu.
- prevent loading and external-link icons from shrinking in constrained menu rows.

* fix(i18n): translate dashboard granularity options

- call t() for granularity option labels in dashboard system settings.
- keep localized text consistent between the select trigger and dropdown items.

* chore(dev): add backend dev service rebuild target

- add a dev-api-rebuild make target to rebuild and start the docker backend service.
- reuse DEV_COMPOSE_FILE and DEV_BACKEND_SERVICE variables to avoid repeated compose config literals.

* fix(i18n): align interface language option labels

- add shared interface language options to keep display names consistent.
- reuse the shared options in the header switcher and profile preferences.
- normalize language codes so zh-CN and zh_CN resolve to Simplified Chinese.

* fix(i18n): add missing frontend translation keys

- route channel key prompts, form validation messages, and channel fallback text through i18n.
- add missing translations across six locales for channels, rankings, billing, and logs.
- update i18n sync reports so literal t() keys are present in the base locale.
2026-05-17 11:45:27 +08:00

453 lines
15 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
enableMultiKey,
disableMultiKey,
deleteMultiKey,
enableAllMultiKeys,
disableAllMultiKeys,
deleteDisabledMultiKeys,
} from '../../api'
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
import {
channelsQueryKeys,
formatTimestamp,
getMultiKeyStatusConfig,
getMultiKeyConfirmMessage,
isDestructiveAction,
} from '../../lib'
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
import { useChannels } from '../channels-provider'
import { StatisticsCard } from './multi-key-statistics-card'
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
type MultiKeyManageDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MultiKeyManageDialog({
open,
onOpenChange,
}: MultiKeyManageDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
// Data state
const [isLoading, setIsLoading] = useState(false)
const [keys, setKeys] = useState<KeyStatus[]>([])
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [enabledCount, setEnabledCount] = useState(0)
const [manualDisabledCount, setManualDisabledCount] = useState(0)
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
// UI state
const [statusFilter, setStatusFilter] = useState<number | null>(null)
const [confirmAction, setConfirmAction] =
useState<MultiKeyConfirmAction | null>(null)
const [isPerformingAction, setIsPerformingAction] = useState(false)
// Reset and load data when dialog opens
useEffect(() => {
if (open && currentRow) {
setCurrentPage(1)
setStatusFilter(null)
loadKeyStatus(1, pageSize, null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
const loadKeyStatus = async (
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
) => {
if (!currentRow) return
setIsLoading(true)
try {
const response = await getMultiKeyStatus(
currentRow.id,
page,
size,
status === null ? undefined : status
)
if (response.success && response.data) {
setKeys(response.data.keys || [])
setTotal(response.data.total || 0)
setCurrentPage(response.data.page || 1)
setPageSize(response.data.page_size || 10)
setTotalPages(response.data.total_pages || 0)
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to load key status')
)
} finally {
setIsLoading(false)
}
}
const handleStatusFilterChange = (value: string) => {
const newFilter = value === 'all' ? null : parseInt(value)
setStatusFilter(newFilter)
setCurrentPage(1)
loadKeyStatus(1, pageSize, newFilter)
}
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage)
loadKeyStatus(newPage, pageSize)
}
const performAction = async () => {
if (!confirmAction || !currentRow) return
setIsPerformingAction(true)
try {
const { type, keyIndex } = confirmAction
let response
// Execute the appropriate action
if (type === 'enable' && keyIndex !== undefined) {
response = await enableMultiKey(currentRow.id, keyIndex)
} else if (type === 'disable' && keyIndex !== undefined) {
response = await disableMultiKey(currentRow.id, keyIndex)
} else if (type === 'delete' && keyIndex !== undefined) {
response = await deleteMultiKey(currentRow.id, keyIndex)
} else if (type === 'enable-all') {
response = await enableAllMultiKeys(currentRow.id)
} else if (type === 'disable-all') {
response = await disableAllMultiKeys(currentRow.id)
} else if (type === 'delete-disabled') {
response = await deleteDisabledMultiKeys(currentRow.id)
}
if (response?.success) {
toast.success(response.message || t('Operation successful'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
const isBulkAction = type.includes('all') || type === 'delete-disabled'
if (isBulkAction) {
setCurrentPage(1)
loadKeyStatus(1, pageSize)
} else {
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || t('Operation failed'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Operation failed')
)
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
}
}
const renderStatusBadge = (status: number) => {
const config = getMultiKeyStatusConfig(status)
return (
<StatusBadge
label={t(config.label)}
variant={config.variant}
showDot
copyable={false}
/>
)
}
const formatKeyTimestamp = (timestamp?: number) => {
if (!timestamp) return '-'
return formatTimestamp(timestamp)
}
if (!currentRow) return null
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Confirmation Dialog */}
<ConfirmDialog
open={confirmAction !== null}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={t('Confirm Action')}
desc={t(getMultiKeyConfirmMessage(confirmAction))}
destructive={isDestructiveAction(confirmAction)}
isLoading={isPerformingAction}
handleConfirm={performAction}
/>
</>
)
}