feat(payment): enhance payment method configuration with icons and improve UI interactions
This commit is contained in:
+99
-79
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -34,12 +34,13 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { ReactIconByName } from '@/components/react-icon-by-name'
|
||||
|
||||
const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Payment method name is required')),
|
||||
type: z.string().min(1, t('Payment method type is required')),
|
||||
color: z.string().min(1, t('Color is required')),
|
||||
type: z.string().min(1, t('Payment type key is required')),
|
||||
icon: z.string().optional(),
|
||||
min_topup: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -52,8 +53,9 @@ const PAYMENT_METHOD_FORM_ID = 'payment-method-form'
|
||||
export type PaymentMethodData = {
|
||||
name: string
|
||||
type: string
|
||||
color: string
|
||||
icon?: string
|
||||
min_topup?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type PaymentMethodDialogProps = {
|
||||
@@ -63,42 +65,14 @@ type PaymentMethodDialogProps = {
|
||||
editData?: PaymentMethodData | null
|
||||
}
|
||||
|
||||
const PAYMENT_TYPES = [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
]
|
||||
|
||||
const getColorPreview = (color: string) => {
|
||||
if (color.includes('var(--')) {
|
||||
return null
|
||||
}
|
||||
return color
|
||||
const PAYMENT_TYPE_ICON_NAMES: Record<string, string> = {
|
||||
alipay: 'SiAlipay',
|
||||
stripe: 'SiStripe',
|
||||
waffo_pancake: 'LuCreditCard',
|
||||
wxpay: 'SiWechat',
|
||||
}
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
{ value: '#1677FF', label: 'Blue (Alipay)' },
|
||||
{ value: '#07C160', label: 'Green (WeChat)' },
|
||||
{ value: '#635BFF', label: 'Purple (Stripe)' },
|
||||
{ value: '#1890FF', label: 'Sky Blue' },
|
||||
{ value: '#52C41A', label: 'Lime Green' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: '#FF4D4F', label: 'Red' },
|
||||
{ value: '#FFA940', label: 'Orange' },
|
||||
].map((preset) => {
|
||||
const previewColor = getColorPreview(preset.value)
|
||||
return {
|
||||
...preset,
|
||||
icon: previewColor ? (
|
||||
<div
|
||||
className='size-4 rounded border'
|
||||
style={{ backgroundColor: previewColor }}
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted size-4 rounded border' />
|
||||
),
|
||||
}
|
||||
})
|
||||
const getDefaultIconName = (type: string) => PAYMENT_TYPE_ICON_NAMES[type] ?? ''
|
||||
|
||||
export function PaymentMethodDialog({
|
||||
open,
|
||||
@@ -109,41 +83,60 @@ export function PaymentMethodDialog({
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
const paymentMethodDialogSchema = createPaymentMethodDialogSchema(t)
|
||||
const paymentTypeOptions = [
|
||||
{
|
||||
iconName: 'SiAlipay',
|
||||
label: `${t('Alipay')} (Epay: alipay)`,
|
||||
name: t('Alipay'),
|
||||
value: 'alipay',
|
||||
},
|
||||
{
|
||||
iconName: 'SiWechat',
|
||||
label: `${t('WeChat Pay')} (Epay: wxpay)`,
|
||||
name: t('WeChat Pay'),
|
||||
value: 'wxpay',
|
||||
},
|
||||
{
|
||||
iconName: 'SiStripe',
|
||||
label: `${t('Stripe')} (stripe)`,
|
||||
name: t('Stripe'),
|
||||
value: 'stripe',
|
||||
},
|
||||
{
|
||||
iconName: 'LuCreditCard',
|
||||
label: 'Waffo Pancake (waffo_pancake)',
|
||||
name: 'Waffo Pancake',
|
||||
value: 'waffo_pancake',
|
||||
},
|
||||
]
|
||||
const getPaymentTypeOption = (value: string) =>
|
||||
paymentTypeOptions.find((option) => option.value === value)
|
||||
|
||||
const form = useForm<PaymentMethodDialogFormValues>({
|
||||
resolver: zodResolver(paymentMethodDialogSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
type: '',
|
||||
color: '',
|
||||
icon: '',
|
||||
min_topup: '',
|
||||
},
|
||||
})
|
||||
|
||||
const colorValue = form.watch('color')
|
||||
|
||||
const colorPreview = useMemo(() => {
|
||||
if (!colorValue) return null
|
||||
try {
|
||||
// For CSS variables like rgba(var(--semi-blue-5), 1), we can't preview accurately
|
||||
// but we can detect common patterns
|
||||
if (colorValue.includes('var(--')) {
|
||||
return null // Can't preview CSS variables reliably
|
||||
}
|
||||
return colorValue
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [colorValue])
|
||||
const iconValue = form.watch('icon')
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
form.reset({
|
||||
name: editData.name,
|
||||
type: editData.type,
|
||||
icon: editData.icon ?? getDefaultIconName(editData.type),
|
||||
min_topup: editData.min_topup ?? '',
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
type: '',
|
||||
color: '',
|
||||
icon: '',
|
||||
min_topup: '',
|
||||
})
|
||||
}
|
||||
@@ -153,7 +146,9 @@ export function PaymentMethodDialog({
|
||||
const data: PaymentMethodData = {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
color: values.color,
|
||||
}
|
||||
if (values.icon && values.icon.trim() !== '') {
|
||||
data.icon = values.icon.trim()
|
||||
}
|
||||
if (values.min_topup && values.min_topup.trim() !== '') {
|
||||
data.min_topup = values.min_topup
|
||||
@@ -215,19 +210,46 @@ export function PaymentMethodDialog({
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<FormLabel>{t('Payment type key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={PAYMENT_TYPES}
|
||||
options={paymentTypeOptions}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter payment type')}
|
||||
searchPlaceholder={t('Search payment types...')}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return
|
||||
const currentIcon = form.getValues('icon')?.trim()
|
||||
const currentName = form.getValues('name')?.trim()
|
||||
const previousOption = getPaymentTypeOption(field.value)
|
||||
const nextOption = getPaymentTypeOption(value)
|
||||
|
||||
field.onChange(value)
|
||||
if (
|
||||
nextOption?.iconName &&
|
||||
(!currentIcon ||
|
||||
currentIcon === previousOption?.iconName)
|
||||
) {
|
||||
form.setValue('icon', nextOption.iconName, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
if (
|
||||
nextOption?.name &&
|
||||
(!currentName || currentName === previousOption?.name)
|
||||
) {
|
||||
form.setValue('name', nextOption.name, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
placeholder={t('Select or enter payment type key')}
|
||||
searchPlaceholder={t('Search payment type keys...')}
|
||||
allowCustomValue
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select from presets or type custom identifier.')}
|
||||
<FormDescription className='leading-relaxed'>
|
||||
{t(
|
||||
'Used to decide the payment flow. Built-in keys include stripe for Stripe and waffo_pancake for Waffo Pancake; other values are sent to Epay as the type parameter.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -236,32 +258,30 @@ export function PaymentMethodDialog({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Color')}</FormLabel>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Combobox
|
||||
options={COLOR_PRESETS}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter color value')}
|
||||
searchPlaceholder={t('Search colors...')}
|
||||
allowCustomValue
|
||||
<Input
|
||||
placeholder={t('e.g., SiAlipay')}
|
||||
{...field}
|
||||
className='flex-1'
|
||||
/>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-9 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
title={colorPreview}
|
||||
{iconValue && (
|
||||
<ReactIconByName
|
||||
name={iconValue}
|
||||
className='text-muted-foreground size-5 shrink-0'
|
||||
title={iconValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select preset or enter custom CSS color value.')}
|
||||
{t(
|
||||
'Enter a react-icons component name. Invalid names show no icon.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
+95
-72
@@ -27,6 +27,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { ReactIconByName } from '@/components/react-icon-by-name'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
@@ -39,47 +40,70 @@ type PaymentMethodsVisualEditorProps = {
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const PAYMENT_TEMPLATES = [
|
||||
{
|
||||
name: 'Alipay',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-blue-5), 1)',
|
||||
name: '支付宝',
|
||||
type: 'alipay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WeChat Pay',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-green-5), 1)',
|
||||
name: '微信',
|
||||
type: 'wxpay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stripe',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-green-5), 1)',
|
||||
name: 'Stripe',
|
||||
type: 'stripe',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Custom',
|
||||
template: {
|
||||
color: 'black',
|
||||
min_topup: '50',
|
||||
name: '自定义1',
|
||||
type: 'custom1',
|
||||
},
|
||||
},
|
||||
]
|
||||
const PAYMENT_TYPE_ICON_NAMES: Record<string, string> = {
|
||||
alipay: 'SiAlipay',
|
||||
stripe: 'SiStripe',
|
||||
waffo_pancake: 'LuCreditCard',
|
||||
wxpay: 'SiWechat',
|
||||
}
|
||||
|
||||
function getDefaultIconName(type: string) {
|
||||
return PAYMENT_TYPE_ICON_NAMES[type] ?? ''
|
||||
}
|
||||
|
||||
function getEffectiveIconName(method: PaymentMethodData) {
|
||||
return method.icon || getDefaultIconName(method.type)
|
||||
}
|
||||
|
||||
export function PaymentMethodsVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: PaymentMethodsVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const paymentTemplates = [
|
||||
{
|
||||
name: t('Epay Alipay'),
|
||||
template: {
|
||||
icon: getDefaultIconName('alipay'),
|
||||
name: '支付宝',
|
||||
type: 'alipay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('Epay WeChat Pay'),
|
||||
template: {
|
||||
icon: getDefaultIconName('wxpay'),
|
||||
name: '微信',
|
||||
type: 'wxpay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('Stripe'),
|
||||
template: {
|
||||
icon: getDefaultIconName('stripe'),
|
||||
min_topup: '10',
|
||||
name: 'Stripe',
|
||||
type: 'stripe',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Waffo Pancake',
|
||||
template: {
|
||||
icon: getDefaultIconName('waffo_pancake'),
|
||||
name: 'Waffo Pancake',
|
||||
type: 'waffo_pancake',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('Custom Epay method'),
|
||||
template: {
|
||||
icon: 'LuCreditCard',
|
||||
min_topup: '50',
|
||||
name: '自定义1',
|
||||
type: 'custom1',
|
||||
},
|
||||
},
|
||||
]
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<PaymentMethodData | null>(null)
|
||||
@@ -98,10 +122,11 @@ export function PaymentMethodsVisualEditor({
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
'type' in item &&
|
||||
'color' in item &&
|
||||
typeof item.name === 'string' &&
|
||||
typeof item.type === 'string' &&
|
||||
typeof item.color === 'string'
|
||||
(!('icon' in item) || typeof item.icon === 'string') &&
|
||||
(!('min_topup' in item) || typeof item.min_topup === 'string') &&
|
||||
(!('color' in item) || typeof item.color === 'string')
|
||||
)
|
||||
}, [value])
|
||||
|
||||
@@ -111,7 +136,8 @@ export function PaymentMethodsVisualEditor({
|
||||
return paymentMethods.filter(
|
||||
(method) =>
|
||||
method.name.toLowerCase().includes(lowerSearch) ||
|
||||
method.type.toLowerCase().includes(lowerSearch)
|
||||
method.type.toLowerCase().includes(lowerSearch) ||
|
||||
getEffectiveIconName(method).toLowerCase().includes(lowerSearch)
|
||||
)
|
||||
}, [paymentMethods, searchText])
|
||||
|
||||
@@ -202,14 +228,6 @@ export function PaymentMethodsVisualEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const getColorPreview = (color: string) => {
|
||||
// For CSS variables, show a placeholder
|
||||
if (color.includes('var(--')) {
|
||||
return null
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
@@ -235,10 +253,10 @@ export function PaymentMethodsVisualEditor({
|
||||
<PopoverContent className='w-60'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Quick insert common payment methods')}
|
||||
{t('Quick insert payment entries')}
|
||||
</p>
|
||||
<div className='space-y-1'>
|
||||
{PAYMENT_TEMPLATES.map((item) => (
|
||||
{paymentTemplates.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
type='button'
|
||||
@@ -297,7 +315,7 @@ export function PaymentMethodsVisualEditor({
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
header: t('Payment type key'),
|
||||
cell: (method) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
@@ -305,23 +323,24 @@ export function PaymentMethodsVisualEditor({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
header: t('Color'),
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (method) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
const iconName = getEffectiveIconName(method)
|
||||
|
||||
return (
|
||||
return iconName ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<ReactIconByName
|
||||
name={iconName}
|
||||
className='text-muted-foreground size-5 shrink-0'
|
||||
title={iconName}
|
||||
/>
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
{iconName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>—</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
@@ -377,7 +396,8 @@ export function PaymentMethodsVisualEditor({
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
const iconName = getEffectiveIconName(method)
|
||||
|
||||
return (
|
||||
<div key={`${method.type}-${index}`} className='p-4'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
@@ -417,19 +437,22 @@ export function PaymentMethodsVisualEditor({
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground min-w-20'>
|
||||
{t('Color:')}
|
||||
{t('Icon')}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
{iconName ? (
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<ReactIconByName
|
||||
name={iconName}
|
||||
className='text-muted-foreground size-5 shrink-0'
|
||||
title={iconName}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{iconName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>—</span>
|
||||
)}
|
||||
</div>
|
||||
{method.min_topup && (
|
||||
<div className='flex items-center gap-2'>
|
||||
|
||||
+741
-663
File diff suppressed because it is too large
Load Diff
+107
-31
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
@@ -51,12 +51,57 @@ import {
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
|
||||
|
||||
type LogTypeValue = (typeof logTypeValues)[number]
|
||||
type LogTypeValue = (typeof LOG_TYPE_FILTERS)[number]['value']
|
||||
const logTypeValueSet = new Set<string>(
|
||||
LOG_TYPE_FILTERS.map((type) => type.value)
|
||||
)
|
||||
|
||||
type CommonLogDraft = {
|
||||
sourceKey: string
|
||||
filters: CommonLogFilters
|
||||
logType: LogTypeValue
|
||||
}
|
||||
|
||||
function isLogTypeValue(value: string): value is LogTypeValue {
|
||||
return (logTypeValues as readonly string[]).includes(value)
|
||||
return logTypeValueSet.has(value)
|
||||
}
|
||||
|
||||
function getLogTypeValue(value: unknown): LogTypeValue {
|
||||
return Array.isArray(value) &&
|
||||
value.length === 1 &&
|
||||
typeof value[0] === 'string' &&
|
||||
isLogTypeValue(value[0])
|
||||
? value[0]
|
||||
: LOG_TYPE_ALL_VALUE
|
||||
}
|
||||
|
||||
function buildSearchSourceKey(values: {
|
||||
startTime?: unknown
|
||||
endTime?: unknown
|
||||
channel?: unknown
|
||||
model?: unknown
|
||||
token?: unknown
|
||||
group?: unknown
|
||||
username?: unknown
|
||||
requestId?: unknown
|
||||
upstreamRequestId?: unknown
|
||||
type?: unknown
|
||||
}) {
|
||||
return [
|
||||
values.startTime,
|
||||
values.endTime,
|
||||
values.channel,
|
||||
values.model,
|
||||
values.token,
|
||||
values.group,
|
||||
values.username,
|
||||
values.requestId,
|
||||
values.upstreamRequestId,
|
||||
Array.isArray(values.type) ? values.type.join(',') : values.type,
|
||||
]
|
||||
.map((value) => String(value ?? ''))
|
||||
.join('\u001f')
|
||||
}
|
||||
|
||||
interface CommonLogsFilterBarProps<TData> {
|
||||
@@ -74,15 +119,21 @@ export function CommonLogsFilterBar<TData>(
|
||||
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
|
||||
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
|
||||
|
||||
const [filters, setFilters] = useState<CommonLogFilters>(() => {
|
||||
const searchState = useMemo<CommonLogDraft>(() => {
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
return { startTime: start, endTime: end }
|
||||
})
|
||||
const [logType, setLogType] = useState<LogTypeValue>(LOG_TYPE_ALL_VALUE)
|
||||
|
||||
useEffect(() => {
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
setFilters({
|
||||
const sourceValues = {
|
||||
startTime: searchParams.startTime,
|
||||
endTime: searchParams.endTime,
|
||||
channel: searchParams.channel,
|
||||
model: searchParams.model,
|
||||
token: searchParams.token,
|
||||
group: searchParams.group,
|
||||
username: searchParams.username,
|
||||
requestId: searchParams.requestId,
|
||||
upstreamRequestId: searchParams.upstreamRequestId,
|
||||
type: searchParams.type,
|
||||
}
|
||||
const filters: CommonLogFilters = {
|
||||
startTime: searchParams.startTime
|
||||
? new Date(searchParams.startTime)
|
||||
: start,
|
||||
@@ -94,16 +145,12 @@ export function CommonLogsFilterBar<TData>(
|
||||
username: searchParams.username || undefined,
|
||||
requestId: searchParams.requestId || undefined,
|
||||
upstreamRequestId: searchParams.upstreamRequestId || undefined,
|
||||
})
|
||||
|
||||
const typeArr = searchParams.type
|
||||
const nextLogType =
|
||||
Array.isArray(typeArr) &&
|
||||
typeArr.length === 1 &&
|
||||
isLogTypeValue(typeArr[0])
|
||||
? typeArr[0]
|
||||
: LOG_TYPE_ALL_VALUE
|
||||
setLogType(nextLogType)
|
||||
}
|
||||
return {
|
||||
sourceKey: buildSearchSourceKey(sourceValues),
|
||||
filters,
|
||||
logType: getLogTypeValue(searchParams.type),
|
||||
}
|
||||
}, [
|
||||
searchParams.startTime,
|
||||
searchParams.endTime,
|
||||
@@ -116,12 +163,25 @@ export function CommonLogsFilterBar<TData>(
|
||||
searchParams.upstreamRequestId,
|
||||
searchParams.type,
|
||||
])
|
||||
const [draft, setDraft] = useState<CommonLogDraft>(() => searchState)
|
||||
const activeDraft =
|
||||
draft.sourceKey === searchState.sourceKey ? draft : searchState
|
||||
const filters = activeDraft.filters
|
||||
const logType = activeDraft.logType
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof CommonLogFilters, value: Date | string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }))
|
||||
setDraft((current) => {
|
||||
const base =
|
||||
current.sourceKey === searchState.sourceKey ? current : searchState
|
||||
return {
|
||||
sourceKey: searchState.sourceKey,
|
||||
filters: { ...base.filters, [field]: value },
|
||||
logType: base.logType,
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
[searchState]
|
||||
)
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
@@ -142,17 +202,23 @@ export function CommonLogsFilterBar<TData>(
|
||||
const handleReset = useCallback(() => {
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
|
||||
setFilters(resetFilters)
|
||||
setLogType(LOG_TYPE_ALL_VALUE)
|
||||
const resetSearch = {
|
||||
type: [LOG_TYPE_ALL_VALUE],
|
||||
startTime: start.getTime(),
|
||||
endTime: end.getTime(),
|
||||
}
|
||||
setDraft({
|
||||
sourceKey: buildSearchSourceKey(resetSearch),
|
||||
filters: resetFilters,
|
||||
logType: LOG_TYPE_ALL_VALUE,
|
||||
})
|
||||
|
||||
navigate({
|
||||
to: '/usage-logs/$section',
|
||||
params: { section: 'common' },
|
||||
search: {
|
||||
page: 1,
|
||||
type: [LOG_TYPE_ALL_VALUE],
|
||||
startTime: start.getTime(),
|
||||
endTime: end.getTime(),
|
||||
...resetSearch,
|
||||
},
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] })
|
||||
@@ -259,9 +325,19 @@ export function CommonLogsFilterBar<TData>(
|
||||
items={logTypeItems}
|
||||
value={logType}
|
||||
onValueChange={(value) => {
|
||||
setLogType(
|
||||
const nextLogType =
|
||||
value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE
|
||||
)
|
||||
setDraft((current) => {
|
||||
const base =
|
||||
current.sourceKey === searchState.sourceKey
|
||||
? current
|
||||
: searchState
|
||||
return {
|
||||
sourceKey: searchState.sourceKey,
|
||||
filters: base.filters,
|
||||
logType: nextLogType,
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
||||
+14
-5
@@ -16,7 +16,7 @@ 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 { useState, useEffect, useCallback } from 'react'
|
||||
import { getTopupInfo } from '../api'
|
||||
import {
|
||||
generatePresetAmounts,
|
||||
@@ -70,6 +70,7 @@ function parsePaymentMethods(
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
type,
|
||||
color: typeof item.color === 'string' ? item.color : undefined,
|
||||
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
||||
min_topup:
|
||||
type === 'stripe' && normalizedMinTopup <= 0
|
||||
? stripeMinTopup
|
||||
@@ -166,7 +167,7 @@ export function useTopupInfo() {
|
||||
const [presetAmounts, setPresetAmounts] = useState<PresetAmount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchTopupInfo = async () => {
|
||||
const fetchTopupInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
@@ -211,11 +212,19 @@ export function useTopupInfo() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopupInfo()
|
||||
}, [])
|
||||
let cancelled = false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!cancelled) void fetchTopupInfo()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchTopupInfo])
|
||||
|
||||
return {
|
||||
topupInfo,
|
||||
|
||||
+16
-4
@@ -20,6 +20,7 @@ import { type ReactNode } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { CreditCard, Landmark } from 'lucide-react'
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
|
||||
import { ReactIconByName } from '@/components/react-icon-by-name'
|
||||
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
|
||||
|
||||
// ============================================================================
|
||||
@@ -57,16 +58,18 @@ function normalizeHttpIconUrl(raw: string | undefined | null): string | null {
|
||||
/**
|
||||
* Get payment method icon component
|
||||
*
|
||||
* When iconUrl is provided, render an <img/> with that URL so custom
|
||||
* gateway logos can be configured per-method.
|
||||
* When icon is provided, render a safe http(s) image URL or resolve it as a
|
||||
* react-icons component name. Invalid configured icons intentionally render
|
||||
* nothing instead of falling back to the payment type.
|
||||
*/
|
||||
export function getPaymentIcon(
|
||||
paymentType: string | undefined,
|
||||
className: string = 'h-4 w-4',
|
||||
iconUrl?: string,
|
||||
icon?: string,
|
||||
altName?: string
|
||||
): ReactNode {
|
||||
const safeIconUrl = normalizeHttpIconUrl(iconUrl)
|
||||
const iconValue = icon?.trim()
|
||||
const safeIconUrl = normalizeHttpIconUrl(iconValue)
|
||||
if (safeIconUrl) {
|
||||
return (
|
||||
<img
|
||||
@@ -80,6 +83,15 @@ export function getPaymentIcon(
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (iconValue) {
|
||||
return (
|
||||
<ReactIconByName
|
||||
name={iconValue}
|
||||
className={className}
|
||||
title={altName || paymentType || iconValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!paymentType) {
|
||||
return <CreditCard className={className} />
|
||||
|
||||
+2
-2
@@ -94,11 +94,11 @@ export interface PaymentMethod {
|
||||
name: string
|
||||
/** Payment method type identifier */
|
||||
type: string
|
||||
/** Optional color for UI display */
|
||||
/** Legacy optional color for UI display */
|
||||
color?: string
|
||||
/** Minimum topup amount for this payment method */
|
||||
min_topup?: number
|
||||
/** Optional icon URL provided by backend (preferred over built-in icons) */
|
||||
/** Optional react-icons component name or safe icon URL */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user