Skip to content
Closed
7 changes: 3 additions & 4 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -1193,15 +1193,14 @@ func CopyChannel(c *gin.Context) {
clone.UsedQuota = 0
}

// insert
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
// insert and keep the generated ID on the cloned record
if err := clone.Insert(); err != nil {
common.SysError("failed to clone channel: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
return
}
model.InitChannelCache()
// success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
common.ApiSuccess(c, gin.H{"id": clone.Id})
}

// MultiKeyManageRequest represents the request for multi-key management operations
Expand Down
26 changes: 18 additions & 8 deletions web/default/src/components/layout/components/chat-presets-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ function ChatMenuItem({
/>
}
>
<span>{preset.name}</span>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
Expand All @@ -77,11 +79,13 @@ function ChatMenuItem({
isActive={false}
className='justify-between'
>
<span>{preset.name}</span>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
{loading ? (
<Loader2 className='h-4 w-4 animate-spin' />
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
) : (
<ExternalLink className='h-4 w-4' />
<ExternalLink className='h-4 w-4 shrink-0' />
)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
Expand All @@ -103,9 +107,12 @@ function DropdownPresetItem({
if (preset.type === 'web') {
return (
<DropdownMenuItem
className='min-w-0'
render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
>
{preset.name}
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
</DropdownMenuItem>
)
}
Expand All @@ -116,12 +123,15 @@ function DropdownPresetItem({
onClick={() => {
if (!loading) void onOpen(preset)
}}
className='min-w-0'
>
{preset.name}
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
{loading ? (
<Loader2 className='ml-auto h-4 w-4 animate-spin opacity-70' />
<Loader2 className='ml-auto h-4 w-4 shrink-0 animate-spin opacity-70' />
) : (
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
<ExternalLink className='ml-auto h-4 w-4 shrink-0 opacity-70' />
)}
</DropdownMenuItem>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function ForgotPasswordForm({
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t('Email')}</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
Expand All @@ -89,8 +89,10 @@ export function ForgotPasswordForm({
)}
/>

<Button className='mt-2' disabled={isLoading || isActive}>
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
<Button type='submit' className='mt-2' disabled={isLoading || isActive}>
{isActive
? t('Resend ({{seconds}}s)', { seconds: secondsLeft })
: t('Send reset email')}
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)

const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
Expand Down Expand Up @@ -280,8 +281,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {

{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
onClick={() => {
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
Expand All @@ -299,11 +299,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Channel')}
desc={`Are you sure you want to delete "${channel.name}"? This action cannot be undone.`}
confirmText='Delete'
confirmText={t('Delete')}
destructive
handleConfirm={() => {
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
isLoading={isDeleting}
handleConfirm={async () => {
setIsDeleting(true)
try {
await handleDeleteChannel(channel.id, queryClient, () => {
setDeleteConfirmOpen(false)
})
} finally {
setIsDeleting(false)
}
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function CopyChannelDialog({
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</DialogFooter>
</DialogContent>
Expand Down
6 changes: 4 additions & 2 deletions web/default/src/features/channels/lib/channel-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,12 @@ export async function handleCopyChannel(
): Promise<void> {
try {
const response = await copyChannel(id, params)
if (response.success && response.data?.id) {
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data.id)
onSuccess?.(response.data?.id ?? 0)
} else {
toast.error(response.message || i18next.t('Failed to copy channel'))
}
} catch (_error) {
toast.error(i18next.t('Failed to copy channel'))
Expand Down
59 changes: 48 additions & 11 deletions web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { useForm, type FieldErrors } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import {
Expand Down Expand Up @@ -151,17 +151,31 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
// For update, fetch fresh data
getApiKey(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformApiKeyToFormDefaults(result.data))
}
})
form.reset(transformApiKeyToFormDefaults(currentRow))

let cancelled = false
getApiKey(currentRow.id)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
form.reset(transformApiKeyToFormDefaults(result.data))
} else {
toast.error(result.message || t(ERROR_MESSAGES.LOAD_FAILED))
}
})
.catch(() => {
if (!cancelled) {
toast.error(t(ERROR_MESSAGES.LOAD_FAILED))
}
})
return () => {
cancelled = true
}
} else if (open && !isUpdate) {
// For create, reset to defaults
form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup))
}
}, [open, isUpdate, currentRow, form, defaultUseAutoGroup])
}, [open, isUpdate, currentRow, form, defaultUseAutoGroup, t])

const onSubmit = async (data: ApiKeyFormValues) => {
setIsSubmitting(true)
Expand Down Expand Up @@ -218,6 +232,29 @@ export function ApiKeysMutateDrawer({
}
}

const onInvalidSubmit = (errors: FieldErrors<ApiKeyFormValues>) => {
if (errors.model_limits || errors.allow_ips) {
setAdvancedOpen(true)
}

if (errors.name) {
toast.error(t('Please enter a name'))
return
}

const firstMessage = Object.values(errors).find(
(error) => typeof error?.message === 'string'
)?.message

toast.error(
typeof firstMessage === 'string'
? firstMessage
: t(ERROR_MESSAGES.UNEXPECTED)
)
}

const submitForm = form.handleSubmit(onSubmit, onInvalidSubmit)

const handleSetExpiry = (months: number, days: number, hours: number) => {
if (months === 0 && days === 0 && hours === 0) {
form.setValue('expired_time', undefined)
Expand Down Expand Up @@ -270,7 +307,7 @@ export function ApiKeysMutateDrawer({
<Form {...form}>
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={submitForm}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
>
<ApiKeyFormSection
Expand Down Expand Up @@ -584,9 +621,9 @@ export function ApiKeysMutateDrawer({
{t('Close')}
</SheetClose>
<Button
form='api-key-form'
type='submit'
type='button'
disabled={isSubmitting}
onClick={() => void submitForm()}
className='w-full sm:w-auto'
>
{isSubmitting ? t('Saving...') : t('Save changes')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function UserSubscriptionsDialog(props: Props) {
</SheetDescription>
</SheetHeader>

<div className='mt-4 space-y-4'>
<div className='space-y-4 px-4 pb-4'>
<div className='flex gap-2'>
<Select
items={[
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
Expand Down Expand Up @@ -151,15 +152,15 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
},
{
id: 'total_amount',
meta: { label: t('Total Quota'), mobileHidden: true },
meta: { label: t('Credited Amount'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Total Quota')} />
<DataTableColumnHeader column={column} title={t('Credited Amount')} />
),
cell: ({ row }) => {
const total = Number(row.original.plan.total_amount || 0)
return (
<span className='text-muted-foreground'>
{total > 0 ? total : t('Unlimited')}
{total > 0 ? formatQuota(total) : t('Unlimited')}
</span>
)
},
Expand Down
Loading