From 555bf38b71b877e89ca5759236226b51d1b5f5cc Mon Sep 17 00:00:00 2001 From: bdim404 Date: Tue, 3 Feb 2026 16:09:50 +0800 Subject: [PATCH] feat: add user preferences migration and context --- qwen3-tts-backend/api/auth.py | 56 +++- qwen3-tts-backend/db/crud.py | 18 +- qwen3-tts-backend/db/models.py | 1 + qwen3-tts-backend/migrate_user_preferences.py | 37 +++ qwen3-tts-backend/schemas/user.py | 8 + .../@/components/ui/radio-group.tsx | 42 +++ qwen3-tts-frontend/package-lock.json | 33 ++ qwen3-tts-frontend/package.json | 1 + qwen3-tts-frontend/src/App.tsx | 18 +- qwen3-tts-frontend/src/components/Navbar.tsx | 105 +++---- .../src/components/OnboardingDialog.tsx | 190 ++++++++++++ .../src/components/tts/CustomVoiceForm.tsx | 10 +- .../src/components/tts/VoiceCloneForm.tsx | 10 +- .../src/components/tts/VoiceDesignForm.tsx | 10 +- .../src/components/ui/radio-group.tsx | 42 +++ .../src/contexts/UserPreferencesContext.tsx | 96 ++++++ qwen3-tts-frontend/src/lib/api.ts | 26 +- qwen3-tts-frontend/src/lib/constants.ts | 3 + qwen3-tts-frontend/src/pages/Home.tsx | 17 +- qwen3-tts-frontend/src/pages/Settings.tsx | 282 ++++++++++++++++++ qwen3-tts-frontend/src/types/auth.ts | 5 + 21 files changed, 931 insertions(+), 79 deletions(-) create mode 100644 qwen3-tts-backend/migrate_user_preferences.py create mode 100644 qwen3-tts-frontend/@/components/ui/radio-group.tsx create mode 100644 qwen3-tts-frontend/src/components/OnboardingDialog.tsx create mode 100644 qwen3-tts-frontend/src/components/ui/radio-group.tsx create mode 100644 qwen3-tts-frontend/src/contexts/UserPreferencesContext.tsx create mode 100644 qwen3-tts-frontend/src/pages/Settings.tsx diff --git a/qwen3-tts-backend/api/auth.py b/qwen3-tts-backend/api/auth.py index 1869d77..1d4cdde 100644 --- a/qwen3-tts-backend/api/auth.py +++ b/qwen3-tts-backend/api/auth.py @@ -14,8 +14,8 @@ from core.security import ( decode_access_token ) from db.database import get_db -from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, update_user_aliyun_key -from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyUpdate, AliyunKeyVerifyResponse +from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, update_user_aliyun_key, get_user_preferences, update_user_preferences +from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyUpdate, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -174,6 +174,32 @@ async def set_aliyun_key( return user +@router.delete("/aliyun-key") +@limiter.limit("5/minute") +async def delete_aliyun_key( + request: Request, + current_user: Annotated[User, Depends(get_current_user)], + db: Session = Depends(get_db) +): + user = update_user_aliyun_key( + db, + user_id=current_user.id, + encrypted_api_key=None + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + prefs = get_user_preferences(db, current_user.id) + if prefs.get("default_backend") == "aliyun": + prefs["default_backend"] = "local" + update_user_preferences(db, current_user.id, prefs) + + return {"message": "Aliyun API key deleted", "preferences_updated": True} + @router.get("/aliyun-key/verify", response_model=AliyunKeyVerifyResponse) @limiter.limit("10/minute") async def verify_aliyun_key( @@ -211,3 +237,29 @@ async def verify_aliyun_key( valid=False, message="Aliyun API key is not working. Please check your API key." ) + +@router.get("/preferences", response_model=UserPreferencesResponse) +@limiter.limit("30/minute") +async def get_preferences( + request: Request, + current_user: Annotated[User, Depends(get_current_user)], + db: Session = Depends(get_db) +): + prefs = get_user_preferences(db, current_user.id) + return UserPreferencesResponse(**prefs) + +@router.put("/preferences") +@limiter.limit("10/minute") +async def update_preferences( + request: Request, + preferences: UserPreferences, + current_user: Annotated[User, Depends(get_current_user)], + db: Session = Depends(get_db) +): + user = update_user_preferences(db, current_user.id, preferences.dict()) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return {"message": "Preferences updated"} diff --git a/qwen3-tts-backend/db/crud.py b/qwen3-tts-backend/db/crud.py index 7bfc6e0..d76f8e1 100644 --- a/qwen3-tts-backend/db/crud.py +++ b/qwen3-tts-backend/db/crud.py @@ -106,7 +106,7 @@ def change_user_password( def update_user_aliyun_key( db: Session, user_id: int, - encrypted_api_key: str + encrypted_api_key: Optional[str] ) -> Optional[User]: user = get_user_by_id(db, user_id) if not user: @@ -229,3 +229,19 @@ def delete_cache_entry(db: Session, cache_id: int, user_id: int) -> bool: db.delete(cache) db.commit() return True + +def get_user_preferences(db: Session, user_id: int) -> dict: + user = get_user_by_id(db, user_id) + if not user or not user.user_preferences: + return {"default_backend": "local", "onboarding_completed": False} + return user.user_preferences + +def update_user_preferences(db: Session, user_id: int, preferences: dict) -> Optional[User]: + user = get_user_by_id(db, user_id) + if not user: + return None + user.user_preferences = preferences + user.updated_at = datetime.utcnow() + db.commit() + db.refresh(user) + return user diff --git a/qwen3-tts-backend/db/models.py b/qwen3-tts-backend/db/models.py index 42f2433..96a91fe 100644 --- a/qwen3-tts-backend/db/models.py +++ b/qwen3-tts-backend/db/models.py @@ -21,6 +21,7 @@ class User(Base): is_active = Column(Boolean, default=True, nullable=False) is_superuser = Column(Boolean, default=False, nullable=False) aliyun_api_key = Column(Text, nullable=True) + user_preferences = Column(JSON, nullable=True, default=lambda: {"default_backend": "local", "onboarding_completed": False}) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/qwen3-tts-backend/migrate_user_preferences.py b/qwen3-tts-backend/migrate_user_preferences.py new file mode 100644 index 0000000..9704168 --- /dev/null +++ b/qwen3-tts-backend/migrate_user_preferences.py @@ -0,0 +1,37 @@ +import sqlite3 +import json +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "qwen_tts.db") + +def migrate(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + try: + cursor.execute("SELECT user_preferences FROM users LIMIT 1") + print("Column user_preferences already exists, skipping migration") + except sqlite3.OperationalError: + print("Adding user_preferences column to users table...") + cursor.execute(""" + ALTER TABLE users + ADD COLUMN user_preferences TEXT DEFAULT NULL + """) + + cursor.execute("SELECT id FROM users") + user_ids = cursor.fetchall() + + default_prefs = json.dumps({"default_backend": "local", "onboarding_completed": False}) + for (user_id,) in user_ids: + cursor.execute( + "UPDATE users SET user_preferences = ? WHERE id = ?", + (default_prefs, user_id) + ) + + conn.commit() + print(f"Migration completed: Added user_preferences column and initialized {len(user_ids)} users") + + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/qwen3-tts-backend/schemas/user.py b/qwen3-tts-backend/schemas/user.py index cdeeb34..ad71f0e 100644 --- a/qwen3-tts-backend/schemas/user.py +++ b/qwen3-tts-backend/schemas/user.py @@ -118,3 +118,11 @@ class AliyunKeyUpdate(BaseModel): class AliyunKeyVerifyResponse(BaseModel): valid: bool message: str + +class UserPreferences(BaseModel): + default_backend: str = Field(default="local", pattern="^(local|aliyun)$") + onboarding_completed: bool = Field(default=False) + +class UserPreferencesResponse(BaseModel): + default_backend: str + onboarding_completed: bool diff --git a/qwen3-tts-frontend/@/components/ui/radio-group.tsx b/qwen3-tts-frontend/@/components/ui/radio-group.tsx new file mode 100644 index 0000000..43b43b4 --- /dev/null +++ b/qwen3-tts-frontend/@/components/ui/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/qwen3-tts-frontend/package-lock.json b/qwen3-tts-frontend/package-lock.json index 3989442..618f181 100644 --- a/qwen3-tts-frontend/package-lock.json +++ b/qwen3-tts-frontend/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -1766,6 +1767,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/qwen3-tts-frontend/package.json b/qwen3-tts-frontend/package.json index 6fbff31..656b8d5 100644 --- a/qwen3-tts-frontend/package.json +++ b/qwen3-tts-frontend/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", diff --git a/qwen3-tts-frontend/src/App.tsx b/qwen3-tts-frontend/src/App.tsx index ffb27c0..37927fc 100644 --- a/qwen3-tts-frontend/src/App.tsx +++ b/qwen3-tts-frontend/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Toaster } from 'sonner' import { ThemeProvider } from '@/contexts/ThemeContext' import { AuthProvider, useAuth } from '@/contexts/AuthContext' +import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext' import { AppProvider } from '@/contexts/AppContext' import { JobProvider } from '@/contexts/JobContext' import { HistoryProvider } from '@/contexts/HistoryContext' @@ -12,6 +13,7 @@ import { SuperAdminRoute } from '@/components/SuperAdminRoute' const Login = lazy(() => import('@/pages/Login')) const Home = lazy(() => import('@/pages/Home')) +const Settings = lazy(() => import('@/pages/Settings')) const UserManagement = lazy(() => import('@/pages/UserManagement')) function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -56,9 +58,10 @@ function App() { - - }> - + + + }> + } /> + + + + } + /> + diff --git a/qwen3-tts-frontend/src/components/Navbar.tsx b/qwen3-tts-frontend/src/components/Navbar.tsx index e1abe8b..b307fe7 100644 --- a/qwen3-tts-frontend/src/components/Navbar.tsx +++ b/qwen3-tts-frontend/src/components/Navbar.tsx @@ -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 ( - <> - ) } diff --git a/qwen3-tts-frontend/src/components/OnboardingDialog.tsx b/qwen3-tts-frontend/src/components/OnboardingDialog.tsx new file mode 100644 index 0000000..238000e --- /dev/null +++ b/qwen3-tts-frontend/src/components/OnboardingDialog.tsx @@ -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 + +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({ + 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 ( + {}}> + e.preventDefault()}> + + + {step === 1 ? '欢迎使用 Qwen3 TTS' : '配置阿里云 API 密钥'} + + + {step === 1 + ? '请选择您的 TTS 后端模式,后续可在设置中修改' + : '请输入您的阿里云 API 密钥,系统将验证其有效性'} + + + + {step === 1 && ( + <> +
+ setSelectedBackend(v as 'local' | 'aliyun')}> +
+ + +
+
+ + +
+
+
+ + + + + + + )} + + {step === 2 && ( +
+ + ( + + API 密钥 + + + + + + )} + /> + + + + + + + + )} +
+
+ ) +} diff --git a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx index 5b780b5..f0cf737 100644 --- a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx @@ -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((_props, ref) => { const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling() const { refresh } = useHistoryContext() + const { preferences } = useUserPreferences() const { register, @@ -75,10 +77,16 @@ const CustomVoiceForm = forwardRef((_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 || '') diff --git a/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx b/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx index 5345a0f..7115170 100644 --- a/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx @@ -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, }) + useEffect(() => { + if (preferences?.default_backend) { + setValue('backend', preferences.default_backend) + } + }, [preferences?.default_backend, setValue]) + useEffect(() => { const fetchData = async () => { try { diff --git a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx index a12e8d2..752f712 100644 --- a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx @@ -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((_props, ref) => { const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling() const { refresh } = useHistoryContext() + const { preferences } = useUserPreferences() const { register, @@ -72,10 +74,16 @@ const VoiceDesignForm = forwardRef((_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 || '') diff --git a/qwen3-tts-frontend/src/components/ui/radio-group.tsx b/qwen3-tts-frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..43b43b4 --- /dev/null +++ b/qwen3-tts-frontend/src/components/ui/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/qwen3-tts-frontend/src/contexts/UserPreferencesContext.tsx b/qwen3-tts-frontend/src/contexts/UserPreferencesContext.tsx new file mode 100644 index 0000000..f331b7b --- /dev/null +++ b/qwen3-tts-frontend/src/contexts/UserPreferencesContext.tsx @@ -0,0 +1,96 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +import { authApi } from '@/lib/api' +import { useAuth } from '@/contexts/AuthContext' +import type { UserPreferences } from '@/types/auth' + +interface UserPreferencesContextType { + preferences: UserPreferences | null + isLoading: boolean + updatePreferences: (prefs: Partial) => Promise + hasAliyunKey: boolean + refetchPreferences: () => Promise +} + +const UserPreferencesContext = createContext(undefined) + +export function UserPreferencesProvider({ children }: { children: ReactNode }) { + const { user, isAuthenticated } = useAuth() + const [preferences, setPreferences] = useState(null) + const [hasAliyunKey, setHasAliyunKey] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + const fetchPreferences = async () => { + if (!isAuthenticated || !user) { + setIsLoading(false) + return + } + + try { + setIsLoading(true) + const [prefs, keyVerification] = await Promise.all([ + authApi.getPreferences(), + authApi.verifyAliyunKey().catch(() => ({ valid: false, message: '' })), + ]) + + setPreferences(prefs) + setHasAliyunKey(keyVerification.valid) + + const cacheKey = `user_preferences_${user.id}` + localStorage.setItem(cacheKey, JSON.stringify(prefs)) + } catch (error) { + const cacheKey = `user_preferences_${user.id}` + const cached = localStorage.getItem(cacheKey) + if (cached) { + setPreferences(JSON.parse(cached)) + } else { + setPreferences({ default_backend: 'local', onboarding_completed: false }) + } + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchPreferences() + }, [isAuthenticated, user?.id]) + + const updatePreferences = async (partialPrefs: Partial) => { + if (!preferences || !user) return + + const newPrefs = { ...preferences, ...partialPrefs } + + const cacheKey = `user_preferences_${user.id}` + localStorage.setItem(cacheKey, JSON.stringify(newPrefs)) + setPreferences(newPrefs) + + try { + await authApi.updatePreferences(newPrefs) + } catch (error) { + localStorage.setItem(cacheKey, JSON.stringify(preferences)) + setPreferences(preferences) + throw error + } + } + + return ( + + {children} + + ) +} + +export function useUserPreferences() { + const context = useContext(UserPreferencesContext) + if (!context) { + throw new Error('useUserPreferences must be used within UserPreferencesProvider') + } + return context +} diff --git a/qwen3-tts-frontend/src/lib/api.ts b/qwen3-tts-frontend/src/lib/api.ts index 3f7d969..bdf4cb7 100644 --- a/qwen3-tts-frontend/src/lib/api.ts +++ b/qwen3-tts-frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import type { LoginRequest, LoginResponse, User, PasswordChangeRequest } from '@/types/auth' +import type { LoginRequest, LoginResponse, User, PasswordChangeRequest, UserPreferences } from '@/types/auth' import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job' import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts' import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user' @@ -185,6 +185,30 @@ export const authApi = { ) return response.data }, + + getPreferences: async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.AUTH.PREFERENCES) + return response.data + }, + + updatePreferences: async (data: UserPreferences): Promise => { + await apiClient.put(API_ENDPOINTS.AUTH.PREFERENCES, data) + }, + + setAliyunKey: async (apiKey: string): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.SET_ALIYUN_KEY, { api_key: apiKey }) + }, + + deleteAliyunKey: async (): Promise => { + await apiClient.delete(API_ENDPOINTS.AUTH.SET_ALIYUN_KEY) + }, + + verifyAliyunKey: async (): Promise<{ valid: boolean; message: string }> => { + const response = await apiClient.get<{ valid: boolean; message: string }>( + API_ENDPOINTS.AUTH.VERIFY_ALIYUN_KEY + ) + return response.data + }, } export const ttsApi = { diff --git a/qwen3-tts-frontend/src/lib/constants.ts b/qwen3-tts-frontend/src/lib/constants.ts index c7a8d05..f24d919 100644 --- a/qwen3-tts-frontend/src/lib/constants.ts +++ b/qwen3-tts-frontend/src/lib/constants.ts @@ -3,6 +3,9 @@ export const API_ENDPOINTS = { LOGIN: '/auth/token', ME: '/auth/me', CHANGE_PASSWORD: '/auth/change-password', + PREFERENCES: '/auth/preferences', + SET_ALIYUN_KEY: '/auth/aliyun-key', + VERIFY_ALIYUN_KEY: '/auth/aliyun-key/verify', }, TTS: { LANGUAGES: '/tts/languages', diff --git a/qwen3-tts-frontend/src/pages/Home.tsx b/qwen3-tts-frontend/src/pages/Home.tsx index f3529f9..cdcaf64 100644 --- a/qwen3-tts-frontend/src/pages/Home.tsx +++ b/qwen3-tts-frontend/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, lazy, Suspense } from 'react' +import { useState, useRef, lazy, Suspense, useEffect } from 'react' import { Navbar } from '@/components/Navbar' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -6,11 +6,13 @@ import { User, Palette, Copy } from 'lucide-react' import type { CustomVoiceFormHandle } from '@/components/tts/CustomVoiceForm' import type { VoiceDesignFormHandle } from '@/components/tts/VoiceDesignForm' import { HistorySidebar } from '@/components/HistorySidebar' +import { OnboardingDialog } from '@/components/OnboardingDialog' import FormSkeleton from '@/components/FormSkeleton' import type { JobType } from '@/types/job' import { jobApi } from '@/lib/api' import { toast } from 'sonner' import { useJobPolling } from '@/hooks/useJobPolling' +import { useUserPreferences } from '@/contexts/UserPreferencesContext' const CustomVoiceForm = lazy(() => import('@/components/tts/CustomVoiceForm')) const VoiceDesignForm = lazy(() => import('@/components/tts/VoiceDesignForm')) @@ -19,11 +21,19 @@ const VoiceCloneForm = lazy(() => import('@/components/tts/VoiceCloneForm')) function Home() { const [currentTab, setCurrentTab] = useState('custom-voice') const [sidebarOpen, setSidebarOpen] = useState(false) + const [showOnboarding, setShowOnboarding] = useState(false) const { loadCompletedJob } = useJobPolling() + const { preferences } = useUserPreferences() const customVoiceFormRef = useRef(null) const voiceDesignFormRef = useRef(null) + useEffect(() => { + if (preferences && !preferences.onboarding_completed) { + setShowOnboarding(true) + } + }, [preferences]) + const handleLoadParams = async (jobId: number, jobType: JobType) => { try { const job = await jobApi.getJob(jobId) @@ -51,6 +61,11 @@ function Home() { return (
+ setShowOnboarding(false)} + /> + setSidebarOpen(!sidebarOpen)} />
diff --git a/qwen3-tts-frontend/src/pages/Settings.tsx b/qwen3-tts-frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..823775c --- /dev/null +++ b/qwen3-tts-frontend/src/pages/Settings.tsx @@ -0,0 +1,282 @@ +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 { Eye, EyeOff, Trash2, Check, X } from 'lucide-react' +import { Navbar } from '@/components/Navbar' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog' +import { useAuth } from '@/contexts/AuthContext' +import { useUserPreferences } from '@/contexts/UserPreferencesContext' +import { authApi } from '@/lib/api' +import type { PasswordChangeRequest } from '@/types/auth' + +const apiKeySchema = z.object({ + api_key: z.string().min(1, '请输入 API 密钥'), +}) + +type ApiKeyFormValues = z.infer + +export default function Settings() { + const { user } = useAuth() + const { preferences, hasAliyunKey, updatePreferences, refetchPreferences } = useUserPreferences() + const [showApiKey, setShowApiKey] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [showPasswordDialog, setShowPasswordDialog] = useState(false) + const [isPasswordLoading, setIsPasswordLoading] = useState(false) + + const form = useForm({ + resolver: zodResolver(apiKeySchema), + defaultValues: { + api_key: '', + }, + }) + + const handleBackendChange = async (value: string) => { + try { + await updatePreferences({ default_backend: value as 'local' | 'aliyun' }) + toast.success(`已切换到${value === 'local' ? '本地' : '阿里云'}模式`) + } catch (error) { + toast.error('保存失败,请重试') + } + } + + const handleUpdateKey = async (data: ApiKeyFormValues) => { + try { + setIsLoading(true) + await authApi.setAliyunKey(data.api_key) + await refetchPreferences() + form.reset() + toast.success('API 密钥已更新并验证成功') + } catch (error: any) { + toast.error(error.message || 'API 密钥验证失败') + } finally { + setIsLoading(false) + } + } + + const handleVerifyKey = async () => { + try { + setIsLoading(true) + const result = await authApi.verifyAliyunKey() + if (result.valid) { + toast.success('API 密钥验证成功') + } else { + toast.error(result.message || 'API 密钥无效') + } + await refetchPreferences() + } catch (error: any) { + toast.error(error.message || '验证失败') + } finally { + setIsLoading(false) + } + } + + const handleDeleteKey = async () => { + if (!confirm('确定要删除阿里云 API 密钥吗?删除后将自动切换到本地模式。')) { + return + } + + try { + setIsLoading(true) + await authApi.deleteAliyunKey() + await refetchPreferences() + toast.success('API 密钥已删除,已切换到本地模式') + } catch (error: any) { + toast.error(error.message || '删除失败') + } finally { + setIsLoading(false) + } + } + + const handleChangePassword = async (data: PasswordChangeRequest) => { + try { + setIsPasswordLoading(true) + await authApi.changePassword(data) + toast.success('密码修改成功') + setShowPasswordDialog(false) + } catch (error: any) { + toast.error(error.message || '密码修改失败') + throw error + } finally { + setIsPasswordLoading(false) + } + } + + if (!user || !preferences) { + return null + } + + return ( +
+ + +
+
+
+

设置

+

管理您的账户设置和偏好

+
+ + + + 后端偏好 + 选择默认的 TTS 后端模式 + + + +
+ + +
+
+ + +
+
+
+
+ + + + 阿里云 API 密钥 + 管理您的阿里云 API 密钥配置 + + +
+ 当前状态: + {hasAliyunKey ? ( + + + 已配置并有效 + + ) : ( + + + 未配置 + + )} +
+ +
+ + ( + + API 密钥 + +
+
+ + +
+
+
+ +
+ )} + /> + +
+ + {hasAliyunKey && ( + <> + + + + )} +
+ + +
+
+ + + + 账户信息 + 您的账户基本信息 + + +
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+ ) +} diff --git a/qwen3-tts-frontend/src/types/auth.ts b/qwen3-tts-frontend/src/types/auth.ts index ec76c89..c4f9be0 100644 --- a/qwen3-tts-frontend/src/types/auth.ts +++ b/qwen3-tts-frontend/src/types/auth.ts @@ -29,3 +29,8 @@ export interface PasswordChangeRequest { new_password: string confirm_password: string } + +export interface UserPreferences { + default_backend: 'local' | 'aliyun' + onboarding_completed: boolean +}