feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
'use client'
import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useDeleteVoice } from '@/lib/query/mutations'
import { AppIcon } from '@/components/ui/icons'
interface Voice {
id: string
name: string
description: string | null
voiceId: string | null
voiceType: string
customVoiceUrl: string | null
voicePrompt: string | null
gender: string | null
language: string
folderId: string | null
}
interface VoiceCardProps {
voice: Voice
onSelect?: (voice: Voice) => void // 选择模式时使用
isSelected?: boolean // 是否被选中
selectionMode?: boolean // 是否在选择模式
}
export function VoiceCard({ voice, onSelect, isSelected = false, selectionMode = false }: VoiceCardProps) {
// 🔥 使用 mutation hook
const deleteVoice = useDeleteVoice()
const t = useTranslations('assetHub')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
// 播放预览
const handlePlay = () => {
if (!voice.customVoiceUrl) return
if (isPlaying && audioRef.current) {
audioRef.current.pause()
setIsPlaying(false)
return
}
const audio = new Audio(voice.customVoiceUrl)
audioRef.current = audio
audio.onended = () => setIsPlaying(false)
audio.onerror = () => setIsPlaying(false)
audio.play()
setIsPlaying(true)
}
// 删除音色
const handleDelete = () => {
deleteVoice.mutate(voice.id, {
onSettled: () => setShowDeleteConfirm(false)
})
}
// 选择模式点击
const handleCardClick = () => {
if (selectionMode && onSelect) {
onSelect(voice)
}
}
// 性别图标
const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''
return (
<div
onClick={handleCardClick}
className={`glass-surface overflow-hidden relative group transition-all ${selectionMode ? 'cursor-pointer hover:ring-2 hover:ring-[var(--glass-focus-ring-strong)]' : ''
} ${isSelected ? 'ring-2 ring-[var(--glass-stroke-focus)]' : ''}`}
>
{/* 选中标记 */}
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 glass-chip glass-chip-info rounded-full flex items-center justify-center z-10 p-0">
<AppIcon name="checkSolid" className="w-4 h-4 text-white" />
</div>
)}
{/* 音色图标区域 */}
<div className="relative bg-[var(--glass-bg-muted)] p-6 flex items-center justify-center">
<div className="w-16 h-16 rounded-full glass-surface-soft flex items-center justify-center">
<AppIcon name="mic" className="w-8 h-8 text-[var(--glass-tone-info-fg)]" />
</div>
{/* 性别标签 */}
{genderIcon && (
<div className="absolute top-2 left-2 glass-chip glass-chip-neutral text-xs px-2 py-0.5 rounded-full">
{genderIcon}
</div>
)}
{/* 试听按钮 */}
{voice.customVoiceUrl && (
<button
onClick={(e) => { e.stopPropagation(); handlePlay() }}
className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isPlaying
? 'glass-btn-tone-info animate-pulse'
: 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'
}`}
>
{isPlaying ? (
<AppIcon name="pause" className="w-5 h-5" />
) : (
<AppIcon name="play" className="w-5 h-5" />
)}
</button>
)}
</div>
{/* 信息区域 */}
<div className="p-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{voice.name}</h3>
{!selectionMode && (
<button
onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(true) }}
className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<AppIcon name="trash" className="w-4 h-4" />
</button>
)}
</div>
{voice.description && (
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.description}</p>
)}
{voice.voicePrompt && !voice.description && (
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voicePrompt}</p>
)}
</div>
{/* 删除确认 */}
{showDeleteConfirm && (
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20">
<div className="glass-surface-modal p-4 m-4" onClick={(e) => e.stopPropagation()}>
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteVoice')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
</div>
</div>
</div>
)}
</div>
)
}
export default VoiceCard