44
qwen3-tts-frontend/src/components/AudioInputSelector.tsx
Normal file
44
qwen3-tts-frontend/src/components/AudioInputSelector.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Upload, Mic } from 'lucide-react'
|
||||
import { FileUploader } from '@/components/FileUploader'
|
||||
import { AudioRecorder } from '@/components/AudioRecorder'
|
||||
|
||||
interface AudioInputSelectorProps {
|
||||
value: File | null
|
||||
onChange: (file: File | null) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function AudioInputSelector({ value, onChange, error }: AudioInputSelectorProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>('upload')
|
||||
|
||||
const handleTabChange = (newTab: string) => {
|
||||
onChange(null)
|
||||
setActiveTab(newTab)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
上传文件
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4" />
|
||||
录制音频
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<FileUploader value={value} onChange={onChange} error={error} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="record" className="mt-4">
|
||||
<AudioRecorder onChange={onChange} />
|
||||
{error && <p className="text-sm text-destructive mt-2">{error}</p>}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
102
qwen3-tts-frontend/src/components/AudioPlayer.module.css
Normal file
102
qwen3-tts-frontend/src/components/AudioPlayer.module.css
Normal file
@@ -0,0 +1,102 @@
|
||||
.audioPlayerWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_container) {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_main) {
|
||||
--rhap_theme-color: hsl(var(--primary));
|
||||
--rhap_background-color: transparent;
|
||||
--rhap_bar-color: hsl(var(--secondary));
|
||||
--rhap_time-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-indicator),
|
||||
.audioPlayerWrapper :global(.rhap_volume-indicator) {
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-filled),
|
||||
.audioPlayerWrapper :global(.rhap_volume-bar) {
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-bar),
|
||||
.audioPlayerWrapper :global(.rhap_volume-container) {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-bar) {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-bar):hover {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-filled) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-indicator) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
top: -4px;
|
||||
margin-left: -7px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-indicator):hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_progress-container) {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_horizontal .rhap_controls-section) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_time) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_button-clear) {
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_button-clear):hover {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_main-controls-button) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.rhap_main-controls-button svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
121
qwen3-tts-frontend/src/components/AudioPlayer.tsx
Normal file
121
qwen3-tts-frontend/src/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useRef, useState, useEffect, useCallback, memo } from 'react'
|
||||
import AudioPlayerLib from 'react-h5-audio-player'
|
||||
import 'react-h5-audio-player/lib/styles.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download } from 'lucide-react'
|
||||
import apiClient from '@/lib/api'
|
||||
import styles from './AudioPlayer.module.css'
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioUrl: string
|
||||
jobId: number
|
||||
}
|
||||
|
||||
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
||||
const [blobUrl, setBlobUrl] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const previousAudioUrlRef = useRef<string>('')
|
||||
const playerRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl || audioUrl === previousAudioUrlRef.current) return
|
||||
|
||||
let active = true
|
||||
const prevBlobUrl = blobUrl
|
||||
|
||||
const fetchAudio = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
if (prevBlobUrl) {
|
||||
URL.revokeObjectURL(prevBlobUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(audioUrl, { responseType: 'blob' })
|
||||
if (active) {
|
||||
const url = URL.createObjectURL(response.data)
|
||||
setBlobUrl(url)
|
||||
previousAudioUrlRef.current = audioUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio:", error)
|
||||
if (active) {
|
||||
setLoadError('Failed to load audio')
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchAudio()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl || audioUrl
|
||||
link.download = `tts-${jobId}-${Date.now()}.wav`
|
||||
link.click()
|
||||
}, [blobUrl, audioUrl, jobId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 border rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 border rounded-lg">
|
||||
<span className="text-sm text-destructive">{loadError}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!blobUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.audioPlayerWrapper}>
|
||||
<AudioPlayerLib
|
||||
src={blobUrl}
|
||||
layout="horizontal"
|
||||
customAdditionalControls={[
|
||||
<Button
|
||||
key="download"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
]}
|
||||
customVolumeControls={[]}
|
||||
showJumpControls={false}
|
||||
volume={1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer'
|
||||
|
||||
export { AudioPlayer }
|
||||
153
qwen3-tts-frontend/src/components/AudioRecorder.tsx
Normal file
153
qwen3-tts-frontend/src/components/AudioRecorder.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Mic, Trash2, RotateCcw, FileAudio } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAudioRecorder } from '@/hooks/useAudioRecorder'
|
||||
import { useAudioValidation } from '@/hooks/useAudioValidation'
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onChange: (file: File | null) => void
|
||||
}
|
||||
|
||||
export function AudioRecorder({ onChange }: AudioRecorderProps) {
|
||||
const {
|
||||
isRecording,
|
||||
recordingDuration,
|
||||
audioBlob,
|
||||
error: recorderError,
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecording,
|
||||
} = useAudioRecorder()
|
||||
|
||||
const { validateAudioFile } = useAudioValidation()
|
||||
const [audioInfo, setAudioInfo] = useState<{ duration: number; size: number } | null>(null)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (recorderError) {
|
||||
toast.error(recorderError)
|
||||
}
|
||||
}, [recorderError])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioBlob) {
|
||||
handleValidateRecording(audioBlob)
|
||||
}
|
||||
}, [audioBlob])
|
||||
|
||||
const handleValidateRecording = async (blob: Blob) => {
|
||||
const file = new File([blob], 'recording.wav', { type: 'audio/wav' })
|
||||
|
||||
const result = await validateAudioFile(file)
|
||||
|
||||
if (result.valid && result.duration) {
|
||||
onChange(file)
|
||||
setAudioInfo({ duration: result.duration, size: file.size })
|
||||
setValidationError(null)
|
||||
} else {
|
||||
setValidationError(result.error || '录音验证失败')
|
||||
clearRecording()
|
||||
onChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = () => {
|
||||
if (!isRecording && !audioBlob) {
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
clearRecording()
|
||||
setAudioInfo(null)
|
||||
setValidationError(null)
|
||||
onChange(null)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' && !isRecording && !audioBlob) {
|
||||
e.preventDefault()
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' && isRecording) {
|
||||
e.preventDefault()
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="p-4 border rounded bg-muted text-muted-foreground text-sm">
|
||||
您的浏览器不支持录音功能
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (audioBlob && audioInfo) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-3 border rounded">
|
||||
<FileAudio className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">录制完成</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} 秒
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleReset}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isRecording ? 'default' : 'outline'}
|
||||
className={`w-full h-24 ${isRecording ? 'animate-pulse' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleMouseDown}
|
||||
onTouchEnd={handleMouseUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Mic className="h-8 w-8" />
|
||||
{isRecording ? (
|
||||
<>
|
||||
<span className="text-lg font-semibold">{recordingDuration.toFixed(1)}s</span>
|
||||
<span className="text-xs">松开完成</span>
|
||||
</>
|
||||
) : (
|
||||
<span>按住录音</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{validationError && (
|
||||
<div className="flex items-center justify-between p-2 border border-destructive rounded bg-destructive/10">
|
||||
<p className="text-sm text-destructive">{validationError}</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
qwen3-tts-frontend/src/components/ErrorBoundary.tsx
Normal file
73
qwen3-tts-frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
<div className="max-w-md w-full space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-destructive">Something went wrong</h1>
|
||||
<p className="text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="p-4 bg-muted rounded-lg text-left">
|
||||
<p className="text-sm font-mono text-destructive break-all">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
89
qwen3-tts-frontend/src/components/FileUploader.tsx
Normal file
89
qwen3-tts-frontend/src/components/FileUploader.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useRef, useState, type ChangeEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Upload, X, FileAudio } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAudioValidation } from '@/hooks/useAudioValidation'
|
||||
|
||||
interface AudioInfo {
|
||||
duration: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface FileUploaderProps {
|
||||
value: File | null
|
||||
onChange: (file: File | null) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function FileUploader({ value, onChange, error }: FileUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { validateAudioFile } = useAudioValidation()
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [audioInfo, setAudioInfo] = useState<AudioInfo | null>(null)
|
||||
|
||||
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsValidating(true)
|
||||
const result = await validateAudioFile(file)
|
||||
setIsValidating(false)
|
||||
|
||||
if (result.valid && result.duration) {
|
||||
onChange(file)
|
||||
setAudioInfo({ duration: result.duration, size: file.size })
|
||||
} else {
|
||||
toast.error(result.error || '文件验证失败')
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(null)
|
||||
setAudioInfo(null)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!value ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isValidating ? '验证中...' : '选择音频文件'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 border rounded">
|
||||
<FileAudio className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{value.name}</p>
|
||||
{audioInfo && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} 秒
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleRemove}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/wav,audio/mp3,audio/mpeg"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
qwen3-tts-frontend/src/components/FormSkeleton.tsx
Normal file
29
qwen3-tts-frontend/src/components/FormSkeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const FormSkeleton = () => {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-24" />
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32" />
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-28" />
|
||||
<div className="h-32 bg-muted rounded" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-36" />
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
</div>
|
||||
|
||||
<div className="h-10 bg-muted rounded w-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormSkeleton;
|
||||
172
qwen3-tts-frontend/src/components/HistoryItem.tsx
Normal file
172
qwen3-tts-frontend/src/components/HistoryItem.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { memo, useState } from 'react'
|
||||
import type { Job } from '@/types/job'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Trash2, AlertCircle, Loader2, Clock, Eye } from 'lucide-react'
|
||||
import { getRelativeTime, cn } from '@/lib/utils'
|
||||
import { JobDetailDialog } from '@/components/JobDetailDialog'
|
||||
|
||||
interface HistoryItemProps {
|
||||
job: Job
|
||||
onDelete: (id: number) => void
|
||||
onLoadParams: (job: Job) => void
|
||||
}
|
||||
|
||||
const jobTypeBadgeVariant = {
|
||||
custom_voice: 'default' as const,
|
||||
voice_design: 'secondary' as const,
|
||||
voice_clone: 'outline' as const,
|
||||
}
|
||||
|
||||
const jobTypeLabel = {
|
||||
custom_voice: '自定义音色',
|
||||
voice_design: '音色设计',
|
||||
voice_clone: '声音克隆',
|
||||
}
|
||||
|
||||
const HistoryItem = memo(({ job, onDelete, onLoadParams }: HistoryItemProps) => {
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false)
|
||||
|
||||
const getLanguageDisplay = (lang: string | undefined) => {
|
||||
if (!lang || lang === 'Auto') return '自动检测'
|
||||
return lang
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
setDetailDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative border rounded-lg p-4 pb-14 space-y-3 hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
job.status === 'failed' && "border-destructive/50"
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Badge variant={jobTypeBadgeVariant[job.type]}>
|
||||
{jobTypeLabel[job.type]}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span>{getRelativeTime(job.created_at)}</span>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{job.parameters?.text && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">文本内容: </span>
|
||||
<span className="line-clamp-2">{job.parameters.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
语言: {getLanguageDisplay(job.parameters?.language)}
|
||||
</div>
|
||||
|
||||
{job.type === 'custom_voice' && job.parameters?.speaker && (
|
||||
<div className="text-muted-foreground">
|
||||
发音人: {job.parameters.speaker}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.type === 'voice_design' && job.parameters?.instruct && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">音色描述: </span>
|
||||
<span className="text-xs line-clamp-2">{job.parameters.instruct}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.type === 'voice_clone' && job.parameters?.ref_text && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">参考文本: </span>
|
||||
<span className="text-xs line-clamp-1">{job.parameters.ref_text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.status === 'processing' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>处理中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.status === 'pending' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>等待处理...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.status === 'failed' && job.error_message && (
|
||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 shrink-0" />
|
||||
<span className="text-sm text-destructive">{job.error_message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-[44px] md:min-h-[36px]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这条历史记录吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(job.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<JobDetailDialog
|
||||
job={job}
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.job.id === nextProps.job.id &&
|
||||
prevProps.job.status === nextProps.job.status &&
|
||||
prevProps.job.updated_at === nextProps.job.updated_at &&
|
||||
prevProps.job.error_message === nextProps.job.error_message
|
||||
)
|
||||
})
|
||||
|
||||
HistoryItem.displayName = 'HistoryItem'
|
||||
|
||||
export { HistoryItem }
|
||||
113
qwen3-tts-frontend/src/components/HistorySidebar.tsx
Normal file
113
qwen3-tts-frontend/src/components/HistorySidebar.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useHistory } from '@/hooks/useHistory'
|
||||
import { HistoryItem } from '@/components/HistoryItem'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, FileAudio, RefreshCw } from 'lucide-react'
|
||||
import type { JobType } from '@/types/job'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface HistorySidebarProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onLoadParams: (jobId: number, jobType: JobType) => Promise<void>
|
||||
}
|
||||
|
||||
function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLoadParams'>) {
|
||||
const { jobs, loading, loadingMore, hasMore, loadMore, deleteJob, error, retry } = useHistory()
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) {
|
||||
loadMore()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
)
|
||||
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, loadMore])
|
||||
|
||||
const handleLoadParams = async (jobId: number, jobType: JobType) => {
|
||||
try {
|
||||
await onLoadParams(jobId, jobType)
|
||||
} catch (error) {
|
||||
toast.error('加载参数失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">历史记录</h2>
|
||||
<p className="text-sm text-muted-foreground">共 {jobs.length} 条记录</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
<Button onClick={retry} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<FileAudio className="w-12 h-12 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium text-muted-foreground">暂无历史记录</p>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
生成语音后,记录将会显示在这里
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{jobs.map((job) => (
|
||||
<HistoryItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDelete={deleteJob}
|
||||
onLoadParams={(job) => handleLoadParams(job.id, job.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div ref={observerTarget} className="py-4 flex justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HistorySidebar({ open, onOpenChange, onLoadParams }: HistorySidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden lg:block w-[320px] border-r h-[calc(100vh-64px)]">
|
||||
<HistorySidebarContent onLoadParams={onLoadParams} />
|
||||
</aside>
|
||||
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md p-0">
|
||||
<HistorySidebarContent onLoadParams={onLoadParams} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
230
qwen3-tts-frontend/src/components/JobDetailDialog.tsx
Normal file
230
qwen3-tts-frontend/src/components/JobDetailDialog.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { memo } from 'react'
|
||||
import type { Job } from '@/types/job'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { ChevronDown, AlertCircle } from 'lucide-react'
|
||||
import { jobApi } from '@/lib/api'
|
||||
|
||||
interface JobDetailDialogProps {
|
||||
job: Job | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const jobTypeBadgeVariant = {
|
||||
custom_voice: 'default' as const,
|
||||
voice_design: 'secondary' as const,
|
||||
voice_clone: 'outline' as const,
|
||||
}
|
||||
|
||||
const jobTypeLabel = {
|
||||
custom_voice: '自定义音色',
|
||||
voice_design: '音色设计',
|
||||
voice_clone: '声音克隆',
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getLanguageDisplay = (lang: string | undefined) => {
|
||||
if (!lang || lang === 'Auto') return '自动检测'
|
||||
return lang
|
||||
}
|
||||
|
||||
const formatBooleanDisplay = (value: boolean | undefined) => {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) => {
|
||||
if (!job) return null
|
||||
|
||||
const canPlay = job.status === 'completed'
|
||||
const audioUrl = canPlay ? jobApi.getAudioUrl(job.id, job.audio_url) : ''
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] bg-background">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Badge variant={jobTypeBadgeVariant[job.type]}>
|
||||
{jobTypeLabel[job.type]}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">#{job.id}</span>
|
||||
</DialogTitle>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTimestamp(job.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)] pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">基本信息</h3>
|
||||
<div className="space-y-1.5 text-sm bg-muted/30 p-3 rounded-lg">
|
||||
{job.type === 'custom_voice' && job.parameters?.speaker && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">发音人: </span>
|
||||
<span>{job.parameters.speaker}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">语言: </span>
|
||||
<span>{getLanguageDisplay(job.parameters?.language)}</span>
|
||||
</div>
|
||||
{job.type === 'voice_clone' && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">快速模式: </span>
|
||||
<span>{formatBooleanDisplay(job.parameters?.x_vector_only_mode)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">使用缓存: </span>
|
||||
<span>{formatBooleanDisplay(job.parameters?.use_cache)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">合成文本</h3>
|
||||
<div className="text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters?.text || <span className="text-muted-foreground">未设置</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.type === 'voice_design' && job.parameters?.instruct && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">音色描述</h3>
|
||||
<div className="text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
{job.parameters.instruct}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.type === 'custom_voice' && job.parameters?.instruct && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">情绪指导</h3>
|
||||
<div className="text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters.instruct}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.type === 'voice_clone' && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">参考文本</h3>
|
||||
<div className="text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters?.ref_text || <span className="text-muted-foreground">未提供</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-semibold hover:text-foreground transition-colors w-full">
|
||||
高级参数
|
||||
<ChevronDown className="w-4 h-4 transition-transform ui-expanded:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="space-y-1.5 text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters?.max_new_tokens !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">最大生成长度: </span>
|
||||
<span>{job.parameters.max_new_tokens}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.temperature !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">温度: </span>
|
||||
<span>{job.parameters.temperature}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.top_k !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Top K: </span>
|
||||
<span>{job.parameters.top_k}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.top_p !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Top P: </span>
|
||||
<span>{job.parameters.top_p}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.repetition_penalty !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">重复惩罚: </span>
|
||||
<span>{job.parameters.repetition_penalty}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{job.status === 'failed' && job.error_message && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-destructive mb-1">错误信息</h3>
|
||||
<p className="text-sm text-destructive">{job.error_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canPlay && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">音频播放</h3>
|
||||
<AudioPlayer audioUrl={audioUrl} jobId={job.id} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
JobDetailDialog.displayName = 'JobDetailDialog'
|
||||
|
||||
export { JobDetailDialog }
|
||||
12
qwen3-tts-frontend/src/components/LoadingScreen.tsx
Normal file
12
qwen3-tts-frontend/src/components/LoadingScreen.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
||||
24
qwen3-tts-frontend/src/components/LoadingState.tsx
Normal file
24
qwen3-tts-frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
interface LoadingStateProps {
|
||||
elapsedTime: number
|
||||
}
|
||||
|
||||
const LoadingState = memo(({ elapsedTime }: LoadingStateProps) => {
|
||||
const displayText = elapsedTime > 60
|
||||
? '生成用时较长,请耐心等待...'
|
||||
: '正在生成音频,请稍候...'
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-6">
|
||||
<p className="text-center text-muted-foreground">{displayText}</p>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已等待 {elapsedTime} 秒
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingState.displayName = 'LoadingState'
|
||||
|
||||
export { LoadingState }
|
||||
50
qwen3-tts-frontend/src/components/Navbar.tsx
Normal file
50
qwen3-tts-frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Menu, LogOut, Users } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface NavbarProps {
|
||||
onToggleSidebar?: () => void
|
||||
}
|
||||
|
||||
export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
const { logout, user } = useAuth()
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
55
qwen3-tts-frontend/src/components/ParamInput.tsx
Normal file
55
qwen3-tts-frontend/src/components/ParamInput.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import type { UseFormRegister, FieldValues, Path } from 'react-hook-form'
|
||||
|
||||
interface ParamInputProps<T extends FieldValues> {
|
||||
name: Path<T>
|
||||
label: string
|
||||
description: string
|
||||
tooltip: string
|
||||
register: UseFormRegister<T>
|
||||
type?: 'number'
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export function ParamInput<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
tooltip,
|
||||
register,
|
||||
type = 'number',
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
}: ParamInputProps<T>) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={name}>{label}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button" asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="text-sm">{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Input
|
||||
{...register(name, { valueAsNumber: type === 'number' })}
|
||||
type={type}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground md:hidden">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
qwen3-tts-frontend/src/components/PresetSelector.tsx
Normal file
37
qwen3-tts-frontend/src/components/PresetSelector.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Preset {
|
||||
label: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface PresetSelectorProps<T extends Preset> {
|
||||
presets: readonly T[]
|
||||
onSelect: (preset: T) => void
|
||||
}
|
||||
|
||||
const PresetSelectorInner = <T extends Preset>({ presets, onSelect }: PresetSelectorProps<T>) => {
|
||||
const presetButtons = useMemo(() => {
|
||||
return presets.map((preset, index) => (
|
||||
<Button
|
||||
key={`${preset.label}-${index}`}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelect(preset)}
|
||||
className="text-xs md:text-sm px-2.5 md:px-3 h-7 md:h-8"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))
|
||||
}, [presets, onSelect])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-2 mt-1.5 md:mt-2">
|
||||
{presetButtons}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PresetSelector = memo(PresetSelectorInner) as typeof PresetSelectorInner
|
||||
21
qwen3-tts-frontend/src/components/SuperAdminRoute.tsx
Normal file
21
qwen3-tts-frontend/src/components/SuperAdminRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import LoadingScreen from '@/components/LoadingScreen'
|
||||
|
||||
export function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading, user } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!user?.is_superuser) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
17
qwen3-tts-frontend/src/components/ThemeToggle.tsx
Normal file
17
qwen3-tts-frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme}>
|
||||
{theme === 'light' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
276
qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx
Normal file
276
qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
import { ParamInput } from '@/components/ParamInput'
|
||||
import { PRESET_INSTRUCTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
||||
import type { Language, Speaker } from '@/types/tts'
|
||||
|
||||
const formSchema = z.object({
|
||||
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
|
||||
language: z.string().min(1, '请选择语言'),
|
||||
speaker: z.string().min(1, '请选择发音人'),
|
||||
instruct: z.string().optional(),
|
||||
max_new_tokens: z.number().min(1).max(10000).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_k: z.number().min(1).max(100).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
repetition_penalty: z.number().min(0).max(2).optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
export interface CustomVoiceFormHandle {
|
||||
loadParams: (params: any) => void
|
||||
}
|
||||
|
||||
const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
const [languages, setLanguages] = useState<Language[]>([])
|
||||
const [speakers, setSpeakers] = useState<Speaker[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
speaker: '',
|
||||
instruct: '',
|
||||
max_new_tokens: 2048,
|
||||
temperature: 0.3,
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
},
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadParams: (params: any) => {
|
||||
setValue('text', params.text || '')
|
||||
setValue('language', params.language || 'Auto')
|
||||
setValue('speaker', params.speaker || '')
|
||||
setValue('instruct', params.instruct || '')
|
||||
setValue('max_new_tokens', params.max_new_tokens || 2048)
|
||||
setValue('temperature', params.temperature || 0.3)
|
||||
setValue('top_k', params.top_k || 20)
|
||||
setValue('top_p', params.top_p || 0.7)
|
||||
setValue('repetition_penalty', params.repetition_penalty || 1.05)
|
||||
}
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [langs, spks] = await Promise.all([
|
||||
ttsApi.getLanguages(),
|
||||
ttsApi.getSpeakers(),
|
||||
])
|
||||
setLanguages(langs)
|
||||
setSpeakers(spks)
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await ttsApi.createCustomVoiceJob(data)
|
||||
toast.success('任务已创建')
|
||||
startPolling(result.job_id)
|
||||
} catch (error) {
|
||||
toast.error('创建任务失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const memoizedAudioUrl = useMemo(() => {
|
||||
if (!currentJob) return ''
|
||||
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
|
||||
}, [currentJob?.id, currentJob?.audio_url])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="language">语言</Label>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value: string) => setValue('language', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.language && (
|
||||
<p className="text-sm text-destructive">{errors.language.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="speaker">发音人</Label>
|
||||
<Select
|
||||
value={watch('speaker')}
|
||||
onValueChange={(value: string) => setValue('speaker', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择发音人" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{speakers.map((speaker) => (
|
||||
<SelectItem key={speaker.name} value={speaker.name}>
|
||||
{speaker.name} - {speaker.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.speaker && (
|
||||
<p className="text-sm text-destructive">{errors.speaker.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="text">合成文本</Label>
|
||||
<Textarea
|
||||
{...register('text')}
|
||||
placeholder="输入要合成的文本..."
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[96px]"
|
||||
/>
|
||||
{errors.text && (
|
||||
<p className="text-sm text-destructive">{errors.text.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="instruct">情绪指导(可选)</Label>
|
||||
<Textarea
|
||||
{...register('instruct')}
|
||||
placeholder="例如:温柔体贴,语速平缓,充满关怀"
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[80px]"
|
||||
/>
|
||||
<PresetSelector
|
||||
presets={PRESET_INSTRUCTS}
|
||||
onSelect={(preset) => {
|
||||
setValue('instruct', preset.instruct)
|
||||
if (preset.text) {
|
||||
setValue('text', preset.text)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors.instruct && (
|
||||
<p className="text-sm text-destructive">{errors.instruct.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" className="w-full">
|
||||
高级选项
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 md:space-y-4 pt-3 md:pt-4">
|
||||
<ParamInput
|
||||
name="max_new_tokens"
|
||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
||||
register={register}
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
<ParamInput
|
||||
name="temperature"
|
||||
label={ADVANCED_PARAMS_INFO.temperature.label}
|
||||
description={ADVANCED_PARAMS_INFO.temperature.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.temperature.tooltip}
|
||||
register={register}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={2}
|
||||
/>
|
||||
<ParamInput
|
||||
name="top_k"
|
||||
label={ADVANCED_PARAMS_INFO.top_k.label}
|
||||
description={ADVANCED_PARAMS_INFO.top_k.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.top_k.tooltip}
|
||||
register={register}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<ParamInput
|
||||
name="top_p"
|
||||
label={ADVANCED_PARAMS_INFO.top_p.label}
|
||||
description={ADVANCED_PARAMS_INFO.top_p.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.top_p.tooltip}
|
||||
register={register}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={1}
|
||||
/>
|
||||
<ParamInput
|
||||
name="repetition_penalty"
|
||||
label={ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
||||
description={ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.repetition_penalty.tooltip}
|
||||
register={register}
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={2}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||
{isLoading ? '创建中...' : '生成语音'}
|
||||
</Button>
|
||||
|
||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||
|
||||
{isCompleted && currentJob && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
})
|
||||
|
||||
export default CustomVoiceForm
|
||||
247
qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx
Normal file
247
qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { AudioInputSelector } from '@/components/AudioInputSelector'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
import { ParamInput } from '@/components/ParamInput'
|
||||
import { PRESET_REF_TEXTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
||||
import type { Language } from '@/types/tts'
|
||||
|
||||
const formSchema = z.object({
|
||||
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
|
||||
language: z.string().optional(),
|
||||
ref_audio: z.instanceof(File, { message: '请上传参考音频' }),
|
||||
ref_text: z.string().optional(),
|
||||
use_cache: z.boolean().optional(),
|
||||
x_vector_only_mode: z.boolean().optional(),
|
||||
max_new_tokens: z.number().min(1).max(10000).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_k: z.number().min(1).max(100).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
repetition_penalty: z.number().min(0).max(2).optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
function VoiceCloneForm() {
|
||||
const [languages, setLanguages] = useState<Language[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
ref_text: '',
|
||||
use_cache: true,
|
||||
x_vector_only_mode: false,
|
||||
max_new_tokens: 2048,
|
||||
temperature: 0.3,
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
} as Partial<FormData>,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const langs = await ttsApi.getLanguages()
|
||||
setLanguages(langs)
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await ttsApi.createVoiceCloneJob({
|
||||
...data,
|
||||
ref_audio: data.ref_audio,
|
||||
})
|
||||
toast.success('任务已创建')
|
||||
startPolling(result.job_id)
|
||||
} catch (error) {
|
||||
toast.error('创建任务失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const memoizedAudioUrl = useMemo(() => {
|
||||
if (!currentJob) return ''
|
||||
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
|
||||
}, [currentJob?.id, currentJob?.audio_url])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="ref_text">参考文稿(可选)</Label>
|
||||
<Textarea
|
||||
{...register('ref_text')}
|
||||
placeholder="参考音频对应的文本..."
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[80px]"
|
||||
/>
|
||||
<PresetSelector
|
||||
presets={PRESET_REF_TEXTS}
|
||||
onSelect={(preset) => setValue('ref_text', preset.text)}
|
||||
/>
|
||||
{errors.ref_text && (
|
||||
<p className="text-sm text-destructive">{errors.ref_text.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="ref_audio">参考音频</Label>
|
||||
<Controller
|
||||
name="ref_audio"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AudioInputSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={errors.ref_audio?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="language">语言(可选)</Label>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value: string) => setValue('language', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="text">合成文本</Label>
|
||||
<Textarea
|
||||
{...register('text')}
|
||||
placeholder="输入要合成的文本..."
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[96px]"
|
||||
/>
|
||||
<PresetSelector
|
||||
presets={PRESET_REF_TEXTS}
|
||||
onSelect={(preset) => setValue('text', preset.text)}
|
||||
/>
|
||||
{errors.text && (
|
||||
<p className="text-sm text-destructive">{errors.text.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="x_vector_only_mode"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="x_vector_only_mode"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="x_vector_only_mode" className="text-sm font-normal">
|
||||
快速模式
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="use_cache"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="use_cache"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="use_cache" className="text-sm font-normal">
|
||||
使用缓存
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" className="w-full">
|
||||
高级选项
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 md:space-y-4 pt-3 md:pt-4">
|
||||
<ParamInput
|
||||
name="max_new_tokens"
|
||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
||||
register={register}
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||
{isLoading ? '创建中...' : '生成语音'}
|
||||
</Button>
|
||||
|
||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||
|
||||
{isCompleted && currentJob && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceCloneForm
|
||||
245
qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx
Normal file
245
qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ttsApi, jobApi } from '@/lib/api'
|
||||
import { useJobPolling } from '@/hooks/useJobPolling'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { PresetSelector } from '@/components/PresetSelector'
|
||||
import { ParamInput } from '@/components/ParamInput'
|
||||
import { PRESET_VOICE_DESIGNS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
||||
import type { Language } from '@/types/tts'
|
||||
|
||||
const formSchema = z.object({
|
||||
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
|
||||
language: z.string().min(1, '请选择语言'),
|
||||
instruct: z.string().min(10, '音色描述至少需要 10 个字符').max(500, '音色描述不能超过 500 字符'),
|
||||
max_new_tokens: z.number().min(1).max(10000).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_k: z.number().min(1).max(100).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
repetition_penalty: z.number().min(0).max(2).optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
export interface VoiceDesignFormHandle {
|
||||
loadParams: (params: any) => void
|
||||
}
|
||||
|
||||
const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
const [languages, setLanguages] = useState<Language[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
instruct: '',
|
||||
max_new_tokens: 2048,
|
||||
temperature: 0.3,
|
||||
top_k: 20,
|
||||
top_p: 0.7,
|
||||
repetition_penalty: 1.05,
|
||||
},
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadParams: (params: any) => {
|
||||
setValue('text', params.text || '')
|
||||
setValue('language', params.language || 'Auto')
|
||||
setValue('instruct', params.instruct || '')
|
||||
setValue('max_new_tokens', params.max_new_tokens || 2048)
|
||||
setValue('temperature', params.temperature || 0.3)
|
||||
setValue('top_k', params.top_k || 20)
|
||||
setValue('top_p', params.top_p || 0.7)
|
||||
setValue('repetition_penalty', params.repetition_penalty || 1.05)
|
||||
}
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const langs = await ttsApi.getLanguages()
|
||||
setLanguages(langs)
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await ttsApi.createVoiceDesignJob(data)
|
||||
toast.success('任务已创建')
|
||||
startPolling(result.job_id)
|
||||
} catch (error) {
|
||||
toast.error('创建任务失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const memoizedAudioUrl = useMemo(() => {
|
||||
if (!currentJob) return ''
|
||||
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
|
||||
}, [currentJob?.id, currentJob?.audio_url])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="language">语言</Label>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value: string) => setValue('language', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.language && (
|
||||
<p className="text-sm text-destructive">{errors.language.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="text">合成文本</Label>
|
||||
<Textarea
|
||||
{...register('text')}
|
||||
placeholder="输入要合成的文本..."
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[96px]"
|
||||
/>
|
||||
{errors.text && (
|
||||
<p className="text-sm text-destructive">{errors.text.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="instruct">音色描述</Label>
|
||||
<Textarea
|
||||
{...register('instruct')}
|
||||
placeholder="例如:成熟男性,低沉磁性,充满权威感"
|
||||
rows={2}
|
||||
className="min-h-[60px] md:min-h-[80px]"
|
||||
/>
|
||||
<PresetSelector
|
||||
presets={PRESET_VOICE_DESIGNS}
|
||||
onSelect={(preset) => {
|
||||
setValue('instruct', preset.instruct)
|
||||
if (preset.text) {
|
||||
setValue('text', preset.text)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors.instruct && (
|
||||
<p className="text-sm text-destructive">{errors.instruct.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" className="w-full">
|
||||
高级选项
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 md:space-y-4 pt-3 md:pt-4">
|
||||
<ParamInput
|
||||
name="max_new_tokens"
|
||||
label={ADVANCED_PARAMS_INFO.max_new_tokens.label}
|
||||
description={ADVANCED_PARAMS_INFO.max_new_tokens.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.max_new_tokens.tooltip}
|
||||
register={register}
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
<ParamInput
|
||||
name="temperature"
|
||||
label={ADVANCED_PARAMS_INFO.temperature.label}
|
||||
description={ADVANCED_PARAMS_INFO.temperature.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.temperature.tooltip}
|
||||
register={register}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={2}
|
||||
/>
|
||||
<ParamInput
|
||||
name="top_k"
|
||||
label={ADVANCED_PARAMS_INFO.top_k.label}
|
||||
description={ADVANCED_PARAMS_INFO.top_k.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.top_k.tooltip}
|
||||
register={register}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<ParamInput
|
||||
name="top_p"
|
||||
label={ADVANCED_PARAMS_INFO.top_p.label}
|
||||
description={ADVANCED_PARAMS_INFO.top_p.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.top_p.tooltip}
|
||||
register={register}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={1}
|
||||
/>
|
||||
<ParamInput
|
||||
name="repetition_penalty"
|
||||
label={ADVANCED_PARAMS_INFO.repetition_penalty.label}
|
||||
description={ADVANCED_PARAMS_INFO.repetition_penalty.description}
|
||||
tooltip={ADVANCED_PARAMS_INFO.repetition_penalty.tooltip}
|
||||
register={register}
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={2}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || isPolling}>
|
||||
{isLoading ? '创建中...' : '生成语音'}
|
||||
</Button>
|
||||
|
||||
{isPolling && <LoadingState elapsedTime={elapsedTime} />}
|
||||
|
||||
{isCompleted && currentJob && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
})
|
||||
|
||||
export default VoiceDesignForm
|
||||
139
qwen3-tts-frontend/src/components/ui/alert-dialog.tsx
Normal file
139
qwen3-tts-frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
36
qwen3-tts-frontend/src/components/ui/badge.tsx
Normal file
36
qwen3-tts-frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
qwen3-tts-frontend/src/components/ui/button.tsx
Normal file
56
qwen3-tts-frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
qwen3-tts-frontend/src/components/ui/card.tsx
Normal file
79
qwen3-tts-frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
qwen3-tts-frontend/src/components/ui/checkbox.tsx
Normal file
30
qwen3-tts-frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
9
qwen3-tts-frontend/src/components/ui/collapsible.tsx
Normal file
9
qwen3-tts-frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
120
qwen3-tts-frontend/src/components/ui/dialog.tsx
Normal file
120
qwen3-tts-frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
178
qwen3-tts-frontend/src/components/ui/form.tsx
Normal file
178
qwen3-tts-frontend/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
22
qwen3-tts-frontend/src/components/ui/input.tsx
Normal file
22
qwen3-tts-frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
qwen3-tts-frontend/src/components/ui/label.tsx
Normal file
24
qwen3-tts-frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
26
qwen3-tts-frontend/src/components/ui/progress.tsx
Normal file
26
qwen3-tts-frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
48
qwen3-tts-frontend/src/components/ui/scroll-area.tsx
Normal file
48
qwen3-tts-frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
158
qwen3-tts-frontend/src/components/ui/select.tsx
Normal file
158
qwen3-tts-frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
31
qwen3-tts-frontend/src/components/ui/separator.tsx
Normal file
31
qwen3-tts-frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
138
qwen3-tts-frontend/src/components/ui/sheet.tsx
Normal file
138
qwen3-tts-frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
28
qwen3-tts-frontend/src/components/ui/slider.tsx
Normal file
28
qwen3-tts-frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
53
qwen3-tts-frontend/src/components/ui/tabs.tsx
Normal file
53
qwen3-tts-frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
54
qwen3-tts-frontend/src/components/ui/textarea.tsx
Normal file
54
qwen3-tts-frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn, debounce } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => internalRef.current!)
|
||||
|
||||
const adjustHeight = React.useCallback((element: HTMLTextAreaElement) => {
|
||||
element.style.height = 'auto'
|
||||
element.style.height = `${element.scrollHeight}px`
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = internalRef.current
|
||||
if (element) {
|
||||
adjustHeight(element)
|
||||
}
|
||||
}, [props.value, props.defaultValue, adjustHeight])
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = internalRef.current
|
||||
if (!element) return
|
||||
|
||||
const handleInput = () => adjustHeight(element)
|
||||
const handleResize = debounce(() => adjustHeight(element), 250)
|
||||
|
||||
element.addEventListener('input', handleInput)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('input', handleInput)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [adjustHeight])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm max-h-[80vh] md:max-h-[400px] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
ref={internalRef}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
28
qwen3-tts-frontend/src/components/ui/tooltip.tsx
Normal file
28
qwen3-tts-frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
48
qwen3-tts-frontend/src/components/users/DeleteUserDialog.tsx
Normal file
48
qwen3-tts-frontend/src/components/users/DeleteUserDialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface DeleteUserDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
user: User | null
|
||||
onConfirm: () => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function DeleteUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: DeleteUserDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您确定要删除用户 <strong>{user?.username}</strong> 吗?
|
||||
<br />
|
||||
此操作不可撤销,该用户的所有数据将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} disabled={isLoading}>
|
||||
{isLoading ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
203
qwen3-tts-frontend/src/components/users/UserDialog.tsx
Normal file
203
qwen3-tts-frontend/src/components/users/UserDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
const editUserFormSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
password: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
is_superuser: z.boolean().default(false),
|
||||
})
|
||||
|
||||
const createUserFormSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
password: z.string().min(8, '密码至少8个字符'),
|
||||
is_active: z.boolean().default(true),
|
||||
is_superuser: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof editUserFormSchema>
|
||||
|
||||
interface UserDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
user?: User | null
|
||||
onSubmit: (data: UserFormValues) => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function UserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: UserDialogProps) {
|
||||
const isEditing = !!user
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(isEditing ? editUserFormSchema : createUserFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
})
|
||||
}
|
||||
}, [user, form])
|
||||
|
||||
const handleSubmit = async (data: UserFormValues) => {
|
||||
await onSubmit(data)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? '编辑用户' : '创建用户'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
密码{isEditing && ' (留空则不修改)'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>激活状态</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_superuser"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>超级管理员</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
87
qwen3-tts-frontend/src/components/users/UserTable.tsx
Normal file
87
qwen3-tts-frontend/src/components/users/UserTable.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Edit, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface UserTableProps {
|
||||
users: User[]
|
||||
isLoading: boolean
|
||||
onEdit: (user: User) => void
|
||||
onDelete: (user: User) => void
|
||||
}
|
||||
|
||||
export function UserTable({ users, isLoading, onEdit, onDelete }: UserTableProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">暂无用户</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-left">
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">用户名</th>
|
||||
<th className="px-4 py-3 font-medium">邮箱</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">角色</th>
|
||||
<th className="px-4 py-3 font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b hover:bg-muted/50">
|
||||
<td className="px-4 py-3">{user.id}</td>
|
||||
<td className="px-4 py-3">{user.username}</td>
|
||||
<td className="px-4 py-3">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||
{user.is_active ? '活跃' : '停用'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={user.is_superuser ? 'destructive' : 'outline'}>
|
||||
{user.is_superuser ? '超级管理员' : '普通用户'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Date(user.created_at).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(user)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user