Add IconLabel component and integrate it into forms for improved accessibility and UI consistency
This commit is contained in:
26
qwen3-tts-frontend/src/components/IconLabel.tsx
Normal file
26
qwen3-tts-frontend/src/components/IconLabel.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import type { ElementType } from 'react'
|
||||||
|
|
||||||
|
interface IconLabelProps {
|
||||||
|
icon: ElementType
|
||||||
|
tooltip: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLabel({ icon: Icon, tooltip, required = false }: IconLabelProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-1 cursor-help">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{required && <span className="text-destructive">*</span>}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ const PresetSelectorInner = <T extends Preset>({ presets, onSelect }: PresetSele
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onSelect(preset)}
|
onClick={() => onSelect(preset)}
|
||||||
className="text-xs md:text-sm px-2 h-6 md:h-7"
|
className="text-xs md:text-sm px-2 py-0.5 h-5"
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -34,19 +34,19 @@ const PresetSelectorInner = <T extends Preset>({ presets, onSelect }: PresetSele
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<div className="flex flex-wrap gap-1 flex-1">
|
<div className="flex flex-wrap gap-1 flex-1">
|
||||||
{presetButtons}
|
{presetButtons}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={handleRandomSelect}
|
onClick={handleRandomSelect}
|
||||||
className="h-6 md:h-7 px-2 flex-shrink-0"
|
className="h-6 w-6 flex-shrink-0"
|
||||||
title="随机选择"
|
title="随机选择"
|
||||||
>
|
>
|
||||||
<Shuffle className="h-3.5 w-3.5" />
|
<Shuffle className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { Globe2, User, Type, Sparkles, Play, Settings } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { IconLabel } from '@/components/IconLabel'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ttsApi, jobApi } from '@/lib/api'
|
import { ttsApi, jobApi } from '@/lib/api'
|
||||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||||
import { LoadingState } from '@/components/LoadingState'
|
import { LoadingState } from '@/components/LoadingState'
|
||||||
@@ -42,6 +44,13 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
const [speakers, setSpeakers] = useState<Speaker[]>([])
|
const [speakers, setSpeakers] = useState<Speaker[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [tempAdvancedParams, setTempAdvancedParams] = useState({
|
||||||
|
max_new_tokens: 2048,
|
||||||
|
temperature: 0.3,
|
||||||
|
top_k: 20,
|
||||||
|
top_p: 0.7,
|
||||||
|
repetition_penalty: 1.05
|
||||||
|
})
|
||||||
|
|
||||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||||
|
|
||||||
@@ -116,9 +125,9 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
}, [currentJob?.id, currentJob?.audio_url])
|
}, [currentJob?.id, currentJob?.audio_url])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="language">语言</Label>
|
<IconLabel icon={Globe2} tooltip="语言" required />
|
||||||
<Select
|
<Select
|
||||||
value={watch('language')}
|
value={watch('language')}
|
||||||
onValueChange={(value: string) => setValue('language', value)}
|
onValueChange={(value: string) => setValue('language', value)}
|
||||||
@@ -139,8 +148,8 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="speaker">发音人</Label>
|
<IconLabel icon={User} tooltip="发音人" required />
|
||||||
<Select
|
<Select
|
||||||
value={watch('speaker')}
|
value={watch('speaker')}
|
||||||
onValueChange={(value: string) => setValue('speaker', value)}
|
onValueChange={(value: string) => setValue('speaker', value)}
|
||||||
@@ -161,8 +170,8 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="text">合成文本</Label>
|
<IconLabel icon={Type} tooltip="合成文本" required />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('text')}
|
{...register('text')}
|
||||||
placeholder="输入要合成的文本..."
|
placeholder="输入要合成的文本..."
|
||||||
@@ -173,8 +182,8 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="instruct">情绪指导(可选)</Label>
|
<IconLabel icon={Sparkles} tooltip="情绪指导(可选)" />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('instruct')}
|
{...register('instruct')}
|
||||||
placeholder="例如:温柔体贴,语速平缓,充满关怀"
|
placeholder="例如:温柔体贴,语速平缓,充满关怀"
|
||||||
@@ -194,68 +203,175 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||||
<CollapsibleTrigger asChild>
|
if (open) {
|
||||||
<Button type="button" variant="ghost" className="w-full py-1.5">
|
setTempAdvancedParams({
|
||||||
|
max_new_tokens: watch('max_new_tokens'),
|
||||||
|
temperature: watch('temperature'),
|
||||||
|
top_k: watch('top_k'),
|
||||||
|
top_p: watch('top_p'),
|
||||||
|
repetition_penalty: watch('repetition_penalty')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setAdvancedOpen(open)
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" className="w-full">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
高级选项
|
高级选项
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</DialogTrigger>
|
||||||
<CollapsibleContent className="space-y-2 pt-2">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ParamInput
|
<DialogHeader>
|
||||||
name="max_new_tokens"
|
<DialogTitle>高级参数设置</DialogTitle>
|
||||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
</DialogHeader>
|
||||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
<div className="space-y-4 py-4">
|
||||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
<div className="space-y-2">
|
||||||
register={register}
|
<Label htmlFor="dialog-max_new_tokens">
|
||||||
min={1}
|
{ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||||
max={10000}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
<ParamInput
|
id="dialog-max_new_tokens"
|
||||||
name="temperature"
|
type="number"
|
||||||
label={ADVANCED_PARAMS_INFO.temperature.label}
|
min={1}
|
||||||
description={ADVANCED_PARAMS_INFO.temperature.description}
|
max={10000}
|
||||||
tooltip={ADVANCED_PARAMS_INFO.temperature.tooltip}
|
value={tempAdvancedParams.max_new_tokens}
|
||||||
register={register}
|
onChange={(e) => setTempAdvancedParams({
|
||||||
step={0.1}
|
...tempAdvancedParams,
|
||||||
min={0}
|
max_new_tokens: parseInt(e.target.value) || 2048
|
||||||
max={2}
|
})}
|
||||||
/>
|
/>
|
||||||
<ParamInput
|
<p className="text-sm text-muted-foreground">
|
||||||
name="top_k"
|
{ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||||
label={ADVANCED_PARAMS_INFO.top_k.label}
|
</p>
|
||||||
description={ADVANCED_PARAMS_INFO.top_k.description}
|
</div>
|
||||||
tooltip={ADVANCED_PARAMS_INFO.top_k.tooltip}
|
<div className="space-y-2">
|
||||||
register={register}
|
<Label htmlFor="dialog-temperature">
|
||||||
min={1}
|
{ADVANCED_PARAMS_INFO.temperature.label}
|
||||||
max={100}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
<ParamInput
|
id="dialog-temperature"
|
||||||
name="top_p"
|
type="number"
|
||||||
label={ADVANCED_PARAMS_INFO.top_p.label}
|
min={0}
|
||||||
description={ADVANCED_PARAMS_INFO.top_p.description}
|
max={2}
|
||||||
tooltip={ADVANCED_PARAMS_INFO.top_p.tooltip}
|
step={0.1}
|
||||||
register={register}
|
value={tempAdvancedParams.temperature}
|
||||||
step={0.1}
|
onChange={(e) => setTempAdvancedParams({
|
||||||
min={0}
|
...tempAdvancedParams,
|
||||||
max={1}
|
temperature: parseFloat(e.target.value) || 0.3
|
||||||
/>
|
})}
|
||||||
<ParamInput
|
/>
|
||||||
name="repetition_penalty"
|
<p className="text-sm text-muted-foreground">
|
||||||
label={ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
{ADVANCED_PARAMS_INFO.temperature.description}
|
||||||
description={ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
</p>
|
||||||
tooltip={ADVANCED_PARAMS_INFO.repetition_penalty.tooltip}
|
</div>
|
||||||
register={register}
|
<div className="space-y-2">
|
||||||
step={0.01}
|
<Label htmlFor="dialog-top_k">
|
||||||
min={0}
|
{ADVANCED_PARAMS_INFO.top_k.label}
|
||||||
max={2}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
</CollapsibleContent>
|
id="dialog-top_k"
|
||||||
</Collapsible>
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={tempAdvancedParams.top_k}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
top_k: parseInt(e.target.value) || 20
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_k.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-top_p">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_p.label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-top_p"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={tempAdvancedParams.top_p}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
top_p: parseFloat(e.target.value) || 0.7
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_p.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-repetition_penalty">
|
||||||
|
{ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-repetition_penalty"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={tempAdvancedParams.repetition_penalty}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
repetition_penalty: parseFloat(e.target.value) || 1.05
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setTempAdvancedParams({
|
||||||
|
max_new_tokens: watch('max_new_tokens'),
|
||||||
|
temperature: watch('temperature'),
|
||||||
|
top_k: watch('top_k'),
|
||||||
|
top_p: watch('top_p'),
|
||||||
|
repetition_penalty: watch('repetition_penalty')
|
||||||
|
})
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValue('max_new_tokens', tempAdvancedParams.max_new_tokens)
|
||||||
|
setValue('temperature', tempAdvancedParams.temperature)
|
||||||
|
setValue('top_k', tempAdvancedParams.top_k)
|
||||||
|
setValue('top_p', tempAdvancedParams.top_p)
|
||||||
|
setValue('repetition_penalty', tempAdvancedParams.repetition_penalty)
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
<TooltipProvider>
|
||||||
{isLoading ? '创建中...' : '生成语音'}
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{isLoading ? '创建中...' : '生成语音'}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>生成语音</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { Settings, Globe2, Type, Play, FileText, Mic, Zap, Database } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { IconLabel } from '@/components/IconLabel'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ttsApi, jobApi } from '@/lib/api'
|
import { ttsApi, jobApi } from '@/lib/api'
|
||||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||||
import { LoadingState } from '@/components/LoadingState'
|
import { LoadingState } from '@/components/LoadingState'
|
||||||
@@ -41,6 +43,9 @@ function VoiceCloneForm() {
|
|||||||
const [languages, setLanguages] = useState<Language[]>([])
|
const [languages, setLanguages] = useState<Language[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [tempAdvancedParams, setTempAdvancedParams] = useState({
|
||||||
|
max_new_tokens: 2048
|
||||||
|
})
|
||||||
|
|
||||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||||
|
|
||||||
@@ -101,9 +106,9 @@ function VoiceCloneForm() {
|
|||||||
}, [currentJob?.id, currentJob?.audio_url])
|
}, [currentJob?.id, currentJob?.audio_url])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="ref_text">参考文稿(可选)</Label>
|
<IconLabel icon={FileText} tooltip="参考文稿(可选)" />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('ref_text')}
|
{...register('ref_text')}
|
||||||
placeholder="参考音频对应的文本..."
|
placeholder="参考音频对应的文本..."
|
||||||
@@ -118,8 +123,8 @@ function VoiceCloneForm() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="ref_audio">参考音频</Label>
|
<IconLabel icon={Mic} tooltip="参考音频" required />
|
||||||
<Controller
|
<Controller
|
||||||
name="ref_audio"
|
name="ref_audio"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -133,8 +138,8 @@ function VoiceCloneForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="language">语言(可选)</Label>
|
<IconLabel icon={Globe2} tooltip="语言(可选)" />
|
||||||
<Select
|
<Select
|
||||||
value={watch('language')}
|
value={watch('language')}
|
||||||
onValueChange={(value: string) => setValue('language', value)}
|
onValueChange={(value: string) => setValue('language', value)}
|
||||||
@@ -152,8 +157,8 @@ function VoiceCloneForm() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="text">合成文本</Label>
|
<IconLabel icon={Type} tooltip="合成文本" required />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('text')}
|
{...register('text')}
|
||||||
placeholder="输入要合成的文本..."
|
placeholder="输入要合成的文本..."
|
||||||
@@ -170,6 +175,7 @@ function VoiceCloneForm() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||||
<Controller
|
<Controller
|
||||||
name="x_vector_only_mode"
|
name="x_vector_only_mode"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -187,6 +193,7 @@ function VoiceCloneForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
<Controller
|
<Controller
|
||||||
name="use_cache"
|
name="use_cache"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -204,29 +211,82 @@ function VoiceCloneForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||||
<CollapsibleTrigger asChild>
|
if (open) {
|
||||||
<Button type="button" variant="ghost" className="w-full py-1.5">
|
setTempAdvancedParams({
|
||||||
|
max_new_tokens: watch('max_new_tokens')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setAdvancedOpen(open)
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" className="w-full">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
高级选项
|
高级选项
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</DialogTrigger>
|
||||||
<CollapsibleContent className="space-y-2 pt-2">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ParamInput
|
<DialogHeader>
|
||||||
name="max_new_tokens"
|
<DialogTitle>高级参数设置</DialogTitle>
|
||||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
</DialogHeader>
|
||||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
<div className="space-y-4 py-4">
|
||||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
<div className="space-y-2">
|
||||||
register={register}
|
<Label htmlFor="dialog-max_new_tokens">
|
||||||
min={1}
|
{ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||||
max={10000}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
</CollapsibleContent>
|
id="dialog-max_new_tokens"
|
||||||
</Collapsible>
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
value={tempAdvancedParams.max_new_tokens}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
max_new_tokens: parseInt(e.target.value) || 2048
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setTempAdvancedParams({ max_new_tokens: watch('max_new_tokens') })
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValue('max_new_tokens', tempAdvancedParams.max_new_tokens)
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
<TooltipProvider>
|
||||||
{isLoading ? '创建中...' : '生成语音'}
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{isLoading ? '创建中...' : '生成语音'}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>生成语音</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { Settings, Globe2, Type, Play, Palette } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { IconLabel } from '@/components/IconLabel'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ttsApi, jobApi } from '@/lib/api'
|
import { ttsApi, jobApi } from '@/lib/api'
|
||||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||||
import { LoadingState } from '@/components/LoadingState'
|
import { LoadingState } from '@/components/LoadingState'
|
||||||
@@ -40,6 +42,13 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
const [languages, setLanguages] = useState<Language[]>([])
|
const [languages, setLanguages] = useState<Language[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [tempAdvancedParams, setTempAdvancedParams] = useState({
|
||||||
|
max_new_tokens: 2048,
|
||||||
|
temperature: 0.3,
|
||||||
|
top_k: 20,
|
||||||
|
top_p: 0.7,
|
||||||
|
repetition_penalty: 1.05
|
||||||
|
})
|
||||||
|
|
||||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||||
|
|
||||||
@@ -107,9 +116,9 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
}, [currentJob?.id, currentJob?.audio_url])
|
}, [currentJob?.id, currentJob?.audio_url])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="language">语言</Label>
|
<IconLabel icon={Globe2} tooltip="语言" required />
|
||||||
<Select
|
<Select
|
||||||
value={watch('language')}
|
value={watch('language')}
|
||||||
onValueChange={(value: string) => setValue('language', value)}
|
onValueChange={(value: string) => setValue('language', value)}
|
||||||
@@ -130,8 +139,8 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="text">合成文本</Label>
|
<IconLabel icon={Type} tooltip="合成文本" required />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('text')}
|
{...register('text')}
|
||||||
placeholder="输入要合成的文本..."
|
placeholder="输入要合成的文本..."
|
||||||
@@ -142,8 +151,8 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="instruct">音色描述</Label>
|
<IconLabel icon={Palette} tooltip="音色描述" required />
|
||||||
<Textarea
|
<Textarea
|
||||||
{...register('instruct')}
|
{...register('instruct')}
|
||||||
placeholder="例如:成熟男性,低沉磁性,充满权威感"
|
placeholder="例如:成熟男性,低沉磁性,充满权威感"
|
||||||
@@ -163,68 +172,175 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||||
<CollapsibleTrigger asChild>
|
if (open) {
|
||||||
<Button type="button" variant="ghost" className="w-full py-1.5">
|
setTempAdvancedParams({
|
||||||
|
max_new_tokens: watch('max_new_tokens'),
|
||||||
|
temperature: watch('temperature'),
|
||||||
|
top_k: watch('top_k'),
|
||||||
|
top_p: watch('top_p'),
|
||||||
|
repetition_penalty: watch('repetition_penalty')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setAdvancedOpen(open)
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" className="w-full">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
高级选项
|
高级选项
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</DialogTrigger>
|
||||||
<CollapsibleContent className="space-y-2 pt-2">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ParamInput
|
<DialogHeader>
|
||||||
name="max_new_tokens"
|
<DialogTitle>高级参数设置</DialogTitle>
|
||||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
</DialogHeader>
|
||||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
<div className="space-y-4 py-4">
|
||||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
<div className="space-y-2">
|
||||||
register={register}
|
<Label htmlFor="dialog-max_new_tokens">
|
||||||
min={1}
|
{ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||||
max={10000}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
<ParamInput
|
id="dialog-max_new_tokens"
|
||||||
name="temperature"
|
type="number"
|
||||||
label={ADVANCED_PARAMS_INFO.temperature.label}
|
min={1}
|
||||||
description={ADVANCED_PARAMS_INFO.temperature.description}
|
max={10000}
|
||||||
tooltip={ADVANCED_PARAMS_INFO.temperature.tooltip}
|
value={tempAdvancedParams.max_new_tokens}
|
||||||
register={register}
|
onChange={(e) => setTempAdvancedParams({
|
||||||
step={0.1}
|
...tempAdvancedParams,
|
||||||
min={0}
|
max_new_tokens: parseInt(e.target.value) || 2048
|
||||||
max={2}
|
})}
|
||||||
/>
|
/>
|
||||||
<ParamInput
|
<p className="text-sm text-muted-foreground">
|
||||||
name="top_k"
|
{ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||||
label={ADVANCED_PARAMS_INFO.top_k.label}
|
</p>
|
||||||
description={ADVANCED_PARAMS_INFO.top_k.description}
|
</div>
|
||||||
tooltip={ADVANCED_PARAMS_INFO.top_k.tooltip}
|
<div className="space-y-2">
|
||||||
register={register}
|
<Label htmlFor="dialog-temperature">
|
||||||
min={1}
|
{ADVANCED_PARAMS_INFO.temperature.label}
|
||||||
max={100}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
<ParamInput
|
id="dialog-temperature"
|
||||||
name="top_p"
|
type="number"
|
||||||
label={ADVANCED_PARAMS_INFO.top_p.label}
|
min={0}
|
||||||
description={ADVANCED_PARAMS_INFO.top_p.description}
|
max={2}
|
||||||
tooltip={ADVANCED_PARAMS_INFO.top_p.tooltip}
|
step={0.1}
|
||||||
register={register}
|
value={tempAdvancedParams.temperature}
|
||||||
step={0.1}
|
onChange={(e) => setTempAdvancedParams({
|
||||||
min={0}
|
...tempAdvancedParams,
|
||||||
max={1}
|
temperature: parseFloat(e.target.value) || 0.3
|
||||||
/>
|
})}
|
||||||
<ParamInput
|
/>
|
||||||
name="repetition_penalty"
|
<p className="text-sm text-muted-foreground">
|
||||||
label={ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
{ADVANCED_PARAMS_INFO.temperature.description}
|
||||||
description={ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
</p>
|
||||||
tooltip={ADVANCED_PARAMS_INFO.repetition_penalty.tooltip}
|
</div>
|
||||||
register={register}
|
<div className="space-y-2">
|
||||||
step={0.01}
|
<Label htmlFor="dialog-top_k">
|
||||||
min={0}
|
{ADVANCED_PARAMS_INFO.top_k.label}
|
||||||
max={2}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
</CollapsibleContent>
|
id="dialog-top_k"
|
||||||
</Collapsible>
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={tempAdvancedParams.top_k}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
top_k: parseInt(e.target.value) || 20
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_k.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-top_p">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_p.label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-top_p"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={tempAdvancedParams.top_p}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
top_p: parseFloat(e.target.value) || 0.7
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.top_p.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-repetition_penalty">
|
||||||
|
{ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-repetition_penalty"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={tempAdvancedParams.repetition_penalty}
|
||||||
|
onChange={(e) => setTempAdvancedParams({
|
||||||
|
...tempAdvancedParams,
|
||||||
|
repetition_penalty: parseFloat(e.target.value) || 1.05
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setTempAdvancedParams({
|
||||||
|
max_new_tokens: watch('max_new_tokens'),
|
||||||
|
temperature: watch('temperature'),
|
||||||
|
top_k: watch('top_k'),
|
||||||
|
top_p: watch('top_p'),
|
||||||
|
repetition_penalty: watch('repetition_penalty')
|
||||||
|
})
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValue('max_new_tokens', tempAdvancedParams.max_new_tokens)
|
||||||
|
setValue('temperature', tempAdvancedParams.temperature)
|
||||||
|
setValue('top_k', tempAdvancedParams.top_k)
|
||||||
|
setValue('top_p', tempAdvancedParams.top_p)
|
||||||
|
setValue('repetition_penalty', tempAdvancedParams.repetition_penalty)
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
<TooltipProvider>
|
||||||
{isLoading ? '创建中...' : '生成语音'}
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{isLoading ? '创建中...' : '生成语音'}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>生成语音</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useRef, lazy, Suspense } from 'react'
|
|||||||
import { Navbar } from '@/components/Navbar'
|
import { Navbar } from '@/components/Navbar'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { User, Palette, Copy } from 'lucide-react'
|
||||||
import type { CustomVoiceFormHandle } from '@/components/tts/CustomVoiceForm'
|
import type { CustomVoiceFormHandle } from '@/components/tts/CustomVoiceForm'
|
||||||
import type { VoiceDesignFormHandle } from '@/components/tts/VoiceDesignForm'
|
import type { VoiceDesignFormHandle } from '@/components/tts/VoiceDesignForm'
|
||||||
import { HistorySidebar } from '@/components/HistorySidebar'
|
import { HistorySidebar } from '@/components/HistorySidebar'
|
||||||
@@ -63,10 +64,19 @@ function Home() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Tabs value={currentTab} onValueChange={setCurrentTab}>
|
<Tabs value={currentTab} onValueChange={setCurrentTab}>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3 h-9">
|
||||||
<TabsTrigger value="custom-voice">自定义音色</TabsTrigger>
|
<TabsTrigger value="custom-voice">
|
||||||
<TabsTrigger value="voice-design">音色设计</TabsTrigger>
|
<User className="h-4 w-4 md:mr-2" />
|
||||||
<TabsTrigger value="voice-clone">声音克隆</TabsTrigger>
|
<span className="hidden md:inline">自定义</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="voice-design">
|
||||||
|
<Palette className="h-4 w-4 md:mr-2" />
|
||||||
|
<span className="hidden md:inline">设计</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="voice-clone">
|
||||||
|
<Copy className="h-4 w-4 md:mr-2" />
|
||||||
|
<span className="hidden md:inline">克隆</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user