feat: add user preferences migration and context

This commit is contained in:
2026-02-03 16:09:50 +08:00
parent abe0dc131b
commit 555bf38b71
21 changed files with 931 additions and 79 deletions

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 || '')

View File

@@ -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 {

View File

@@ -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 || '')

View 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 }