init commit

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 15:34:31 +08:00
commit 80513a3258
141 changed files with 24966 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

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

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

View 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

View 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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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