feat: add user preferences migration and context
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
import { Menu, LogOut, Users, KeyRound } from 'lucide-react'
|
||||
import { Menu, LogOut, Users, Settings } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { authApi } from '@/lib/api'
|
||||
import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog'
|
||||
import type { PasswordChangeRequest } from '@/types/auth'
|
||||
|
||||
interface NavbarProps {
|
||||
onToggleSidebar?: () => void
|
||||
@@ -15,72 +10,46 @@ interface NavbarProps {
|
||||
|
||||
export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
const { logout, user } = useAuth()
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false)
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false)
|
||||
|
||||
const handlePasswordChange = async (data: PasswordChangeRequest) => {
|
||||
try {
|
||||
setIsChangingPassword(true)
|
||||
await authApi.changePassword(data)
|
||||
toast.success('密码修改成功')
|
||||
setPasswordDialogOpen(false)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '密码修改失败')
|
||||
} finally {
|
||||
setIsChangingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="h-16 border-b bg-background flex items-center px-4 gap-4">
|
||||
{onToggleSidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleSidebar}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<nav className="h-16 border-b bg-background flex items-center px-4 gap-4">
|
||||
{onToggleSidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleSidebar}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<Link to="/">
|
||||
<h1 className="text-sm md:text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity">
|
||||
Qwen3-TTS-WebUI
|
||||
</h1>
|
||||
<div className="flex-1">
|
||||
<Link to="/">
|
||||
<h1 className="text-sm md:text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity">
|
||||
Qwen3-TTS-WebUI
|
||||
</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.is_superuser && (
|
||||
<Link to="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.is_superuser && (
|
||||
<Link to="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<KeyRound className="h-5 w-5" />
|
||||
)}
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ChangePasswordDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={setPasswordDialogOpen}
|
||||
onSubmit={handlePasswordChange}
|
||||
isLoading={isChangingPassword}
|
||||
/>
|
||||
</>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
190
qwen3-tts-frontend/src/components/OnboardingDialog.tsx
Normal file
190
qwen3-tts-frontend/src/components/OnboardingDialog.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { authApi } from '@/lib/api'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
|
||||
const apiKeySchema = z.object({
|
||||
api_key: z.string().min(1, '请输入 API 密钥'),
|
||||
})
|
||||
|
||||
type ApiKeyFormValues = z.infer<typeof apiKeySchema>
|
||||
|
||||
interface OnboardingDialogProps {
|
||||
open: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedBackend, setSelectedBackend] = useState<'local' | 'aliyun'>('local')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { updatePreferences, refetchPreferences } = useUserPreferences()
|
||||
|
||||
const form = useForm<ApiKeyFormValues>({
|
||||
resolver: zodResolver(apiKeySchema),
|
||||
defaultValues: {
|
||||
api_key: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleSkip = async () => {
|
||||
try {
|
||||
await updatePreferences({
|
||||
default_backend: 'local',
|
||||
onboarding_completed: true,
|
||||
})
|
||||
toast.success('已跳过配置,默认使用本地模式')
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
toast.error('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (selectedBackend === 'local') {
|
||||
handleComplete('local')
|
||||
} else {
|
||||
setStep(2)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async (backend: 'local' | 'aliyun') => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await updatePreferences({
|
||||
default_backend: backend,
|
||||
onboarding_completed: true,
|
||||
})
|
||||
toast.success(`配置完成,默认使用${backend === 'local' ? '本地' : '阿里云'}模式`)
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
toast.error('保存配置失败,请重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyAndComplete = async (data: ApiKeyFormValues) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await authApi.setAliyunKey(data.api_key)
|
||||
await refetchPreferences()
|
||||
await handleComplete('aliyun')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'API 密钥验证失败,请检查后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-[500px]" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 1 ? '欢迎使用 Qwen3 TTS' : '配置阿里云 API 密钥'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 1
|
||||
? '请选择您的 TTS 后端模式,后续可在设置中修改'
|
||||
: '请输入您的阿里云 API 密钥,系统将验证其有效性'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-4 py-4">
|
||||
<RadioGroup value={selectedBackend} onValueChange={(v) => setSelectedBackend(v as 'local' | 'aliyun')}>
|
||||
<div className="flex items-center space-x-3 border rounded-lg p-4 hover:bg-accent/50 cursor-pointer">
|
||||
<RadioGroupItem value="local" id="local" />
|
||||
<Label htmlFor="local" className="flex-1 cursor-pointer">
|
||||
<div className="font-medium">本地模型(推荐)</div>
|
||||
<div className="text-sm text-muted-foreground">免费使用本地 Qwen3-TTS 模型</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 border rounded-lg p-4 hover:bg-accent/50 cursor-pointer">
|
||||
<RadioGroupItem value="aliyun" id="aliyun" />
|
||||
<Label htmlFor="aliyun" className="flex-1 cursor-pointer">
|
||||
<div className="font-medium">阿里云 API</div>
|
||||
<div className="text-sm text-muted-foreground">需要配置 API 密钥,按量计费</div>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleSkip}>
|
||||
跳过配置
|
||||
</Button>
|
||||
<Button type="button" onClick={handleNextStep}>
|
||||
下一步
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleVerifyAndComplete)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API 密钥</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? '验证中...' : '验证并完成'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
@@ -56,6 +57,7 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -75,10 +77,16 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
backend: 'local',
|
||||
backend: preferences?.default_backend || 'local',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences?.default_backend) {
|
||||
setValue('backend', preferences.default_backend)
|
||||
}
|
||||
}, [preferences?.default_backend, setValue])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadParams: (params: any) => {
|
||||
setValue('text', params.text || '')
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { FileUploader } from '@/components/FileUploader'
|
||||
@@ -54,6 +55,7 @@ function VoiceCloneForm() {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -76,10 +78,16 @@ function VoiceCloneForm() {
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
backend: 'local',
|
||||
backend: preferences?.default_backend || 'local',
|
||||
} as Partial<FormData>,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences?.default_backend) {
|
||||
setValue('backend', preferences.default_backend)
|
||||
}
|
||||
}, [preferences?.default_backend, setValue])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
@@ -54,6 +55,7 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
const { refresh } = useHistoryContext()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -72,10 +74,16 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
backend: 'local',
|
||||
backend: preferences?.default_backend || 'local',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences?.default_backend) {
|
||||
setValue('backend', preferences.default_backend)
|
||||
}
|
||||
}, [preferences?.default_backend, setValue])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadParams: (params: any) => {
|
||||
setValue('text', params.text || '')
|
||||
|
||||
42
qwen3-tts-frontend/src/components/ui/radio-group.tsx
Normal file
42
qwen3-tts-frontend/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
Reference in New Issue
Block a user