refactor: rename backend/frontend dirs and remove NovelWriter submodule
- Rename qwen3-tts-backend → canto-backend - Rename qwen3-tts-frontend → canto-frontend - Remove NovelWriter embedded repo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
VITE_API_URL=/api
|
||||
VITE_APP_NAME=Qwen3-TTS
|
||||
27
qwen3-tts-frontend/.gitignore
vendored
27
qwen3-tts-frontend/.gitignore
vendored
@@ -1,27 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qwen3-TTS-WebUI</title>
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (theme === 'dark' || (!theme && systemDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6383
qwen3-tts-frontend/package-lock.json
generated
6383
qwen3-tts-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "qwen3-tts-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.13.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.8.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2",
|
||||
"react-dom": "^19.2",
|
||||
"@arraypress/waveform-player": "^1.0.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,114 +0,0 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext'
|
||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext'
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import LoadingScreen from '@/components/LoadingScreen'
|
||||
import { SuperAdminRoute } from '@/components/SuperAdminRoute'
|
||||
|
||||
const Login = lazy(() => import('@/pages/Login'))
|
||||
const Settings = lazy(() => import('@/pages/Settings'))
|
||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||
const Audiobook = lazy(() => import('@/pages/Audiobook'))
|
||||
const AdminStats = lazy(() => import('@/pages/AdminStats'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/audiobook" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<UserPreferencesProvider>
|
||||
<Toaster position="top-center" offset="16px" />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/audiobook" replace />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<SuperAdminRoute>
|
||||
<UserManagement />
|
||||
</SuperAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audiobook"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Audiobook />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/stats"
|
||||
element={
|
||||
<SuperAdminRoute>
|
||||
<AdminStats />
|
||||
</SuperAdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</UserPreferencesProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,80 +0,0 @@
|
||||
.audioPlayerWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-player) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-btn) {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
background: transparent;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-btn:hover) {
|
||||
color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-canvas) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-title) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-body) {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-track) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-info) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-text) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audioPlayerWrapper :global(.waveform-time) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.compact .downloadButton {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useRef, useState, useEffect, useCallback, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import WaveformPlayer from '@arraypress/waveform-player'
|
||||
import '@arraypress/waveform-player/dist/waveform-player.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
|
||||
text?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const AudioPlayer = memo(({ audioUrl, jobId, compact }: AudioPlayerProps) => {
|
||||
const { t } = useTranslation('common')
|
||||
const { theme } = useTheme()
|
||||
const [blobUrl, setBlobUrl] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const previousAudioUrlRef = useRef<string>('')
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const playerInstanceRef = useRef<WaveformPlayer | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
const cacheKey = `${audioUrl}::${retryCount}`
|
||||
if (cacheKey === previousAudioUrlRef.current) return
|
||||
|
||||
let active = true
|
||||
const prevBlobUrl = blobUrl
|
||||
|
||||
const fetchAudio = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
setIsPending(false)
|
||||
|
||||
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 = cacheKey
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (active) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 404) {
|
||||
setIsPending(true)
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (active) setRetryCount(c => c + 1)
|
||||
}, 3000)
|
||||
} else {
|
||||
console.error("Failed to load audio:", error)
|
||||
setLoadError(t('failedToLoadAudio'))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchAudio()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current)
|
||||
retryTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [audioUrl, retryCount, blobUrl, t])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !blobUrl) return
|
||||
|
||||
const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db'
|
||||
const progressColor = theme === 'dark' ? '#a78bfa' : '#7c3aed'
|
||||
|
||||
const player = new WaveformPlayer(containerRef.current, {
|
||||
url: blobUrl,
|
||||
waveformStyle: 'mirror',
|
||||
height: compact ? 32 : 60,
|
||||
barWidth: compact ? 2 : 3,
|
||||
barSpacing: 1,
|
||||
samples: compact ? 80 : 200,
|
||||
waveformColor,
|
||||
progressColor,
|
||||
showTime: !compact,
|
||||
showPlaybackSpeed: false,
|
||||
autoplay: false,
|
||||
enableMediaSession: true,
|
||||
})
|
||||
|
||||
playerInstanceRef.current = player
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const buttons = containerRef.current.querySelectorAll('button')
|
||||
buttons.forEach(btn => {
|
||||
if (!btn.hasAttribute('type')) {
|
||||
btn.setAttribute('type', 'button')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
if (playerInstanceRef.current) {
|
||||
playerInstanceRef.current.destroy()
|
||||
playerInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [blobUrl, theme])
|
||||
|
||||
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 || isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 border rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">{t('loadingAudio')}</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
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<audio
|
||||
src={blobUrl}
|
||||
controls
|
||||
className="w-full h-8"
|
||||
style={{ colorScheme: 'dark' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.audioPlayerWrapper}>
|
||||
<div ref={containerRef} className={styles.waveformContainer} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer'
|
||||
|
||||
export { AudioPlayer }
|
||||
@@ -1,123 +0,0 @@
|
||||
.panel {
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
background: rgb(249 115 22 / 0.12);
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.1);
|
||||
border-top: 2px solid rgb(249 115 22 / 0.4);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(249 115 22 / 0.85);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground) / 0.75);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveformWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-player) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-btn) {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
background: transparent;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-btn:hover) {
|
||||
color: rgb(249 115 22);
|
||||
border-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-canvas) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-title) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-body) {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-track) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-info) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-text) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-time) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.waveformInner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.subtitleName {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: rgb(249 115 22);
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.subtitleText {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useRef, useState, useEffect, memo } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import WaveformPlayer from '@arraypress/waveform-player'
|
||||
import '@arraypress/waveform-player/dist/waveform-player.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import apiClient from '@/lib/api'
|
||||
import { audiobookApi, type AudiobookSegment } from '@/lib/api/audiobook'
|
||||
import styles from './ChapterPlayer.module.css'
|
||||
|
||||
interface TimelineItem {
|
||||
seg: AudiobookSegment
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
|
||||
const ChapterPlayer = memo(({
|
||||
projectId,
|
||||
chapterIndex,
|
||||
chapterTitle,
|
||||
segments,
|
||||
onClose,
|
||||
}: {
|
||||
projectId: number
|
||||
chapterIndex: number
|
||||
chapterTitle: string
|
||||
segments: AudiobookSegment[]
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const [blobUrl, setBlobUrl] = useState<string>('')
|
||||
const [isLoadingChapter, setIsLoadingChapter] = useState(true)
|
||||
const [isLoadingTimeline, setIsLoadingTimeline] = useState(false)
|
||||
const [currentSeg, setCurrentSeg] = useState<AudiobookSegment | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const playerRef = useRef<WaveformPlayer | null>(null)
|
||||
const timelineRef = useRef<TimelineItem[]>([])
|
||||
const blobUrlRef = useRef<string>('')
|
||||
|
||||
const doneSegs = segments.filter(s => s.status === 'done')
|
||||
const doneSegIds = doneSegs.map(s => s.id).join(',')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setIsLoadingChapter(true)
|
||||
setCurrentSeg(null)
|
||||
timelineRef.current = []
|
||||
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current)
|
||||
blobUrlRef.current = ''
|
||||
setBlobUrl('')
|
||||
}
|
||||
|
||||
apiClient.get(audiobookApi.getDownloadUrl(projectId, chapterIndex), { responseType: 'blob' })
|
||||
.then(res => {
|
||||
if (!active) return
|
||||
const url = URL.createObjectURL(res.data)
|
||||
blobUrlRef.current = url
|
||||
setBlobUrl(url)
|
||||
setIsLoadingChapter(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setIsLoadingChapter(false)
|
||||
})
|
||||
|
||||
return () => { active = false }
|
||||
}, [projectId, chapterIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!doneSegIds) return
|
||||
let active = true
|
||||
setIsLoadingTimeline(true)
|
||||
timelineRef.current = []
|
||||
|
||||
const build = async () => {
|
||||
let cumTime = 0
|
||||
const result: TimelineItem[] = []
|
||||
for (const seg of doneSegs) {
|
||||
if (!active) break
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
audiobookApi.getSegmentAudioUrl(projectId, seg.id),
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const duration = await new Promise<number>(resolve => {
|
||||
const a = new Audio(url)
|
||||
a.addEventListener('loadedmetadata', () => { URL.revokeObjectURL(url); resolve(a.duration || 0) })
|
||||
a.addEventListener('error', () => { URL.revokeObjectURL(url); resolve(0) })
|
||||
})
|
||||
result.push({ seg, startTime: cumTime, endTime: cumTime + duration })
|
||||
cumTime += duration
|
||||
} catch {
|
||||
result.push({ seg, startTime: cumTime, endTime: cumTime + 1 })
|
||||
cumTime += 1
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
timelineRef.current = result
|
||||
setIsLoadingTimeline(false)
|
||||
}
|
||||
}
|
||||
|
||||
build()
|
||||
return () => { active = false }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doneSegIds, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !blobUrl) return
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
playerRef.current = null
|
||||
}
|
||||
|
||||
const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db'
|
||||
const progressColor = '#f97316'
|
||||
|
||||
const player = new WaveformPlayer(containerRef.current, {
|
||||
url: blobUrl,
|
||||
waveformStyle: 'mirror',
|
||||
height: 56,
|
||||
barWidth: 2,
|
||||
barSpacing: 1,
|
||||
samples: 260,
|
||||
waveformColor,
|
||||
progressColor,
|
||||
showTime: true,
|
||||
showPlaybackSpeed: false,
|
||||
autoplay: false,
|
||||
enableMediaSession: true,
|
||||
onTimeUpdate: (ct: number) => {
|
||||
const tl = timelineRef.current
|
||||
const item = tl.find(t => ct >= t.startTime && ct < t.endTime)
|
||||
setCurrentSeg(item?.seg ?? null)
|
||||
},
|
||||
})
|
||||
playerRef.current = player
|
||||
|
||||
setTimeout(() => {
|
||||
containerRef.current?.querySelectorAll('button').forEach(btn => {
|
||||
if (!btn.hasAttribute('type')) btn.setAttribute('type', 'button')
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
playerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [blobUrl, theme])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>正在播放</span>
|
||||
<span className={styles.chapterName}>{chapterTitle}</span>
|
||||
{isLoadingTimeline && !isLoadingChapter && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 ml-auto">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />字幕轨道加载中…
|
||||
</span>
|
||||
)}
|
||||
<Button type="button" size="icon" variant="ghost" className="h-5 w-5 shrink-0" onClick={onClose}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingChapter ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />合并音频中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.waveformWrapper}>
|
||||
<div ref={containerRef} className={styles.waveformInner} />
|
||||
</div>
|
||||
{currentSeg && (
|
||||
<div className={styles.subtitle}>
|
||||
<span className={styles.subtitleName}>{currentSeg.character_name || '旁白'}</span>
|
||||
<span className={styles.subtitleText}>{currentSeg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChapterPlayer.displayName = 'ChapterPlayer'
|
||||
export { ChapterPlayer }
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useRef, useState, type ChangeEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { t } = useTranslation('voice')
|
||||
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 || t('validationFailed'))
|
||||
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 ? t('validating') : t('selectAudioFile')}
|
||||
</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)} {t('seconds')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
interface IconLabelProps {
|
||||
icon: ElementType
|
||||
tooltip: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export function IconLabel({ icon: Icon, tooltip, required = false }: IconLabelProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 cursor-help">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{required && <span className="text-destructive">*</span>}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Job } from '@/types/job'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 formatTimestamp = (timestamp: string, locale: string) => {
|
||||
return new Date(timestamp).toLocaleString(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) => {
|
||||
const { t, i18n } = useTranslation(['job', 'common'])
|
||||
|
||||
if (!job) return null
|
||||
|
||||
const jobTypeLabel = {
|
||||
custom_voice: t('job:typeCustomVoice'),
|
||||
voice_design: t('job:typeVoiceDesign'),
|
||||
voice_clone: t('job:typeVoiceClone'),
|
||||
}
|
||||
|
||||
const getLanguageDisplay = (lang: string | undefined) => {
|
||||
if (!lang || lang === 'Auto') return t('job:autoDetect')
|
||||
return lang
|
||||
}
|
||||
|
||||
const formatBooleanDisplay = (value: boolean | undefined) => {
|
||||
return value ? t('common:yes') : t('common:no')
|
||||
}
|
||||
|
||||
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, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
<DialogDescription>{t('job:detailsDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)] px-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">{t('job:basicInfo')}</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">{t('job:speaker')}</span>
|
||||
<span>{job.parameters.speaker}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:language')}</span>
|
||||
<span>{getLanguageDisplay(job.parameters?.language)}</span>
|
||||
</div>
|
||||
{job.type === 'voice_clone' && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:fastMode')}</span>
|
||||
<span>{formatBooleanDisplay(job.parameters?.x_vector_only_mode)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:useCache')}</span>
|
||||
<span>{formatBooleanDisplay(job.parameters?.use_cache)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">{t('job:synthesisText')}</h3>
|
||||
<div className="text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters?.text || <span className="text-muted-foreground">{t('job:notSet')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.type === 'voice_design' && job.parameters?.instruct && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">{t('job:voiceDescription')}</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">{t('job:emotionGuidance')}</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">{t('job:referenceText')}</h3>
|
||||
<div className="text-sm bg-muted/30 p-3 rounded-lg border">
|
||||
{job.parameters?.ref_text || <span className="text-muted-foreground">{t('job:notProvided')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-semibold hover:text-foreground transition-colors w-full">
|
||||
{t('job:advancedParameters')}
|
||||
<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">{t('job:maxNewTokens')}</span>
|
||||
<span>{job.parameters.max_new_tokens}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.temperature !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:temperature')}</span>
|
||||
<span>{job.parameters.temperature}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.top_k !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:topK')}</span>
|
||||
<span>{job.parameters.top_k}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.top_p !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:topP')}</span>
|
||||
<span>{job.parameters.top_p}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.parameters?.repetition_penalty !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('job:repetitionPenalty')}</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">{t('job:errorMessage')}</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">{t('job:audioPlayback')}</h3>
|
||||
<AudioPlayer audioUrl={audioUrl} jobId={job.id} text={job.parameters?.text} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
JobDetailDialog.displayName = 'JobDetailDialog'
|
||||
|
||||
export { JobDetailDialog }
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface LoadingStateProps {
|
||||
elapsedTime: number
|
||||
}
|
||||
|
||||
const LoadingState = memo(({ elapsedTime }: LoadingStateProps) => {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const displayText = elapsedTime > 60
|
||||
? t('generationTakingLong')
|
||||
: t('generatingAudio')
|
||||
|
||||
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">
|
||||
{t('waitedSeconds', { seconds: elapsedTime })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingState.displayName = 'LoadingState'
|
||||
|
||||
export { LoadingState }
|
||||
@@ -1,77 +0,0 @@
|
||||
import { LogOut, Users, Settings, Globe, BookOpen, BarChart2 } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||
|
||||
export function Navbar() {
|
||||
const { logout, user } = useAuth()
|
||||
const { changeLanguage } = useUserPreferences()
|
||||
const { t, i18n } = useTranslation(['nav', 'constants'])
|
||||
|
||||
return (
|
||||
<nav className="h-16 flex items-center justify-end px-4 gap-2">
|
||||
<Link to="/audiobook">
|
||||
<Button variant="ghost" size="icon">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{user?.is_superuser && (
|
||||
<Link to="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{user?.is_superuser && (
|
||||
<Link to="/admin/stats">
|
||||
<Button variant="ghost" size="icon">
|
||||
<BarChart2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Globe className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => changeLanguage('zh-CN')}>
|
||||
{t('constants:uiLanguages.zh-CN')} {i18n.language === 'zh-CN' && '✓'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => changeLanguage('zh-TW')}>
|
||||
{t('constants:uiLanguages.zh-TW')} {i18n.language === 'zh-TW' && '✓'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => changeLanguage('en-US')}>
|
||||
{t('constants:uiLanguages.en-US')} {i18n.language === 'en-US' && '✓'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => changeLanguage('ja-JP')}>
|
||||
{t('constants:uiLanguages.ja-JP')} {i18n.language === 'ja-JP' && '✓'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => changeLanguage('ko-KR')}>
|
||||
{t('constants:uiLanguages.ko-KR')} {i18n.language === 'ko-KR' && '✓'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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}</>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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-[51] 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,
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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 }
|
||||
@@ -1,57 +0,0 @@
|
||||
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",
|
||||
xs: "h-6 rounded-md px-2 text-xs",
|
||||
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 }
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
@@ -1,30 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,120 +0,0 @@
|
||||
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-[51] 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,
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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 }
|
||||
@@ -1,24 +0,0 @@
|
||||
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 }
|
||||
@@ -1,26 +0,0 @@
|
||||
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 }
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,48 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,158 +0,0 @@
|
||||
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-[200] 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,
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,138 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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 tabsTriggerVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 h-full 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"text-muted-foreground hover:bg-primary/10 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm",
|
||||
secondary:
|
||||
"text-muted-foreground hover:bg-secondary/50 data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground data-[state=active]:shadow-sm",
|
||||
outline:
|
||||
"text-muted-foreground hover:bg-accent/10 data-[state=active]:bg-accent data-[state=active]:text-accent-foreground data-[state=active]:shadow-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface TabsTriggerProps
|
||||
extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>,
|
||||
VariantProps<typeof tabsTriggerVariants> {}
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
TabsTriggerProps
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(tabsTriggerVariants({ variant }), 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 }
|
||||
@@ -1,59 +0,0 @@
|
||||
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'
|
||||
const maxHeight = window.innerWidth >= 768
|
||||
? Math.min(400, window.innerHeight * 0.5)
|
||||
: window.innerHeight * 0.6
|
||||
const newHeight = Math.min(element.scrollHeight, maxHeight)
|
||||
element.style.height = `${newHeight}px`
|
||||
element.style.overflowY = element.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||
}, [])
|
||||
|
||||
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 resize-none overflow-hidden",
|
||||
className
|
||||
)}
|
||||
ref={internalRef}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,28 +0,0 @@
|
||||
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 }
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { PasswordChangeRequest } from '@/types/auth'
|
||||
|
||||
const passwordChangeSchema = z.object({
|
||||
current_password: z.string().min(1, '请输入当前密码'),
|
||||
new_password: z
|
||||
.string()
|
||||
.min(8, '密码至少8个字符')
|
||||
.regex(/[A-Z]/, '密码必须包含至少一个大写字母')
|
||||
.regex(/[a-z]/, '密码必须包含至少一个小写字母')
|
||||
.regex(/\d/, '密码必须包含至少一个数字'),
|
||||
confirm_password: z.string().min(1, '请确认新密码'),
|
||||
}).refine((data) => data.new_password === data.confirm_password, {
|
||||
message: '两次输入的密码不一致',
|
||||
path: ['confirm_password'],
|
||||
})
|
||||
|
||||
type PasswordChangeFormValues = z.infer<typeof passwordChangeSchema>
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (data: PasswordChangeRequest) => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: ChangePasswordDialogProps) {
|
||||
const form = useForm<PasswordChangeFormValues>({
|
||||
resolver: zodResolver(passwordChangeSchema),
|
||||
defaultValues: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (data: PasswordChangeFormValues) => {
|
||||
await onSubmit(data)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && !isLoading) {
|
||||
form.reset()
|
||||
}
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入当前密码和新密码。新密码至少8个字符,包含大小写字母和数字。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="current_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>当前密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="new_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>新密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirm_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>确认新密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||
{isLoading ? '提交中...' : '确认修改'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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 className="max-w-[90vw] sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您确定要删除用户 <strong>{user?.username}</strong> 吗?
|
||||
<br />
|
||||
此操作不可撤销,该用户的所有数据将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<AlertDialogCancel disabled={isLoading} className="w-full sm:w-auto">取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} disabled={isLoading} className="w-full sm:w-auto">
|
||||
{isLoading ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 createUserFormSchema = (t: (key: string) => string) => z.object({
|
||||
username: z.string().min(3, t('user:validation.usernameMinLength')).max(20, t('user:validation.usernameMaxLength')),
|
||||
email: z.string().email(t('user:validation.emailInvalid')),
|
||||
password: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
is_superuser: z.boolean(),
|
||||
can_use_nsfw: z.boolean(),
|
||||
})
|
||||
|
||||
interface UserDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
user?: User | null
|
||||
onSubmit: (data: any) => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function UserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: UserDialogProps) {
|
||||
const { t } = useTranslation(['user', 'common'])
|
||||
const isEditing = !!user
|
||||
|
||||
const userFormSchema = createUserFormSchema(t)
|
||||
type UserFormValues = z.infer<typeof userFormSchema>
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
can_use_nsfw: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
can_use_nsfw: user.can_use_nsfw ?? false,
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
can_use_nsfw: 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] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? t('user:editUserDialog') : t('user:createUserDialog')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? t('user:editUserDescription') : t('user:createUserDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('user:username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('user:email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{isEditing ? t('user:passwordOptional') : t('user:password')}
|
||||
</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>{t('user:isActive')}</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>{t('user:isSuperuser')}</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="can_use_nsfw"
|
||||
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>{t('user:canUseNsfw')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('user:canUseNsfwDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||
{isLoading ? t('user:saving') : t('common:save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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) {
|
||||
const { t, i18n } = useTranslation(['user', 'common'])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">{t('common:loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">{t('user:noUsers')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block 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">{t('user:username')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('user:email')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('common:status')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('user:role')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('common:actions')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('user:createdAt')}</th>
|
||||
<th className="px-4 py-3 font-medium text-right">{t('common:actions')}</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 ? t('user:active') : t('user:inactive')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={user.is_superuser ? 'destructive' : 'outline'}>
|
||||
{user.is_superuser ? t('user:superuser') : t('user:normalUser')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(user.is_superuser || user.can_use_nsfw) && (
|
||||
<Badge variant="destructive">{t('user:nsfwPermission')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Date(user.created_at).toLocaleString(i18n.language)}
|
||||
</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>
|
||||
|
||||
<div className="md:hidden space-y-4">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{user.username}</div>
|
||||
<div className="flex 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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">ID:</span>
|
||||
<span>{user.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('user:email')}:</span>
|
||||
<span className="truncate ml-2">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('common:status')}:</span>
|
||||
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||
{user.is_active ? t('user:active') : t('user:inactive')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('user:role')}:</span>
|
||||
<Badge variant={user.is_superuser ? 'destructive' : 'outline'}>
|
||||
{user.is_superuser ? t('user:superuser') : t('user:normalUser')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('common:actions')}:</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(user.is_superuser || user.can_use_nsfw) && (
|
||||
<Badge variant="destructive">{t('user:nsfwPermission')}</Badge>
|
||||
)}
|
||||
{!user.is_superuser && !user.can_use_nsfw && (
|
||||
<span className="text-xs text-muted-foreground">{t('user:noPermission')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('user:createdAt')}:</span>
|
||||
<span className="text-xs">{new Date(user.created_at).toLocaleString(i18n.language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { authApi } from '@/lib/api'
|
||||
import type { User, LoginRequest, AuthState } from '@/types/auth'
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (credentials: LoginRequest) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation('auth')
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
let storedToken = localStorage.getItem('token')
|
||||
if (!storedToken && import.meta.env.DEV) {
|
||||
const res = await fetch('/api/auth/dev-token')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
storedToken = data.access_token
|
||||
localStorage.setItem('token', storedToken!)
|
||||
}
|
||||
}
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
const currentUser = await authApi.getCurrentUser()
|
||||
setUser(currentUser)
|
||||
}
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token')
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
try {
|
||||
const response = await authApi.login(credentials)
|
||||
const newToken = response.access_token
|
||||
|
||||
localStorage.setItem('token', newToken)
|
||||
setToken(newToken)
|
||||
|
||||
const currentUser = await authApi.getCurrentUser()
|
||||
setUser(currentUser)
|
||||
|
||||
toast.success(t('loginSuccess'))
|
||||
navigate('/')
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.detail || t('loginFailedCheckCredentials')
|
||||
toast.error(message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
toast.success(t('logoutSuccess'))
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
token,
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!token && !!user,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: 'light' | 'dark'
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<'light' | 'dark'>(() => {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme')
|
||||
if (!storedTheme) {
|
||||
const newTheme = e.matches ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||||
}, [])
|
||||
|
||||
const setTheme = (newTheme: 'light' | 'dark') => {
|
||||
setThemeState(newTheme)
|
||||
|
||||
try {
|
||||
localStorage.setItem('theme', newTheme)
|
||||
} catch (error) {}
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { authApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import type { UserPreferences } from '@/types/auth'
|
||||
import i18n from '@/locales'
|
||||
import { loadFontsForLanguage, detectBrowserLanguage } from '@/lib/fontManager'
|
||||
|
||||
interface UserPreferencesContextType {
|
||||
preferences: UserPreferences | null
|
||||
isLoading: boolean
|
||||
updatePreferences: (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
refetchPreferences: () => Promise<void>
|
||||
isBackendAvailable: (backend: string) => boolean
|
||||
changeLanguage: (lang: 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'ko-KR') => Promise<void>
|
||||
}
|
||||
|
||||
const UserPreferencesContext = createContext<UserPreferencesContextType | undefined>(undefined)
|
||||
|
||||
export function UserPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
if (!isAuthenticated || !user) {
|
||||
const browserLang = detectBrowserLanguage()
|
||||
loadFontsForLanguage(browserLang)
|
||||
setPreferences(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const prefs = await authApi.getPreferences()
|
||||
|
||||
setPreferences(prefs)
|
||||
|
||||
const lang = prefs.language || detectBrowserLanguage()
|
||||
loadFontsForLanguage(lang)
|
||||
await i18n.changeLanguage(lang)
|
||||
|
||||
const cacheKey = `user_preferences_${user.id}`
|
||||
localStorage.setItem(cacheKey, JSON.stringify(prefs))
|
||||
} catch (error) {
|
||||
const cacheKey = `user_preferences_${user.id}`
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
if (cached) {
|
||||
const cachedPrefs = JSON.parse(cached)
|
||||
setPreferences(cachedPrefs)
|
||||
if (cachedPrefs.language) {
|
||||
loadFontsForLanguage(cachedPrefs.language)
|
||||
}
|
||||
} else {
|
||||
const browserLang = detectBrowserLanguage()
|
||||
loadFontsForLanguage(browserLang)
|
||||
setPreferences({ default_backend: 'local', onboarding_completed: false })
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreferences()
|
||||
}, [isAuthenticated, user?.id])
|
||||
|
||||
const updatePreferences = async (partialPrefs: Partial<UserPreferences>) => {
|
||||
if (!preferences || !user) return
|
||||
|
||||
const newPrefs = { ...preferences, ...partialPrefs }
|
||||
|
||||
const cacheKey = `user_preferences_${user.id}`
|
||||
localStorage.setItem(cacheKey, JSON.stringify(newPrefs))
|
||||
setPreferences(newPrefs)
|
||||
|
||||
try {
|
||||
await authApi.updatePreferences(newPrefs)
|
||||
} catch (error) {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(preferences))
|
||||
setPreferences(preferences)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const isBackendAvailable = (backend: string) => {
|
||||
return backend === 'local'
|
||||
}
|
||||
|
||||
const changeLanguage = async (lang: 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'ko-KR') => {
|
||||
loadFontsForLanguage(lang)
|
||||
await i18n.changeLanguage(lang)
|
||||
await updatePreferences({ language: lang })
|
||||
}
|
||||
|
||||
return (
|
||||
<UserPreferencesContext.Provider
|
||||
value={{
|
||||
preferences,
|
||||
isLoading,
|
||||
updatePreferences,
|
||||
refetchPreferences: fetchPreferences,
|
||||
isBackendAvailable,
|
||||
changeLanguage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserPreferencesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useUserPreferences() {
|
||||
const context = useContext(UserPreferencesContext)
|
||||
if (!context) {
|
||||
throw new Error('useUserPreferences must be used within UserPreferencesProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
|
||||
const HIGH_QUALITY_AUDIO_CONSTRAINTS = {
|
||||
audio: {
|
||||
sampleRate: { ideal: 48000 },
|
||||
channelCount: { ideal: 2 },
|
||||
echoCancellation: { ideal: false },
|
||||
noiseSuppression: { ideal: false },
|
||||
autoGainControl: { ideal: false }
|
||||
}
|
||||
}
|
||||
|
||||
interface UseAudioRecorderReturn {
|
||||
isRecording: boolean
|
||||
recordingDuration: number
|
||||
audioBlob: Blob | null
|
||||
error: string | null
|
||||
isSupported: boolean
|
||||
startRecording: () => Promise<void>
|
||||
stopRecording: () => void
|
||||
clearRecording: () => void
|
||||
}
|
||||
|
||||
async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const audioContext = new AudioContext()
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
|
||||
const numberOfChannels = audioBuffer.numberOfChannels
|
||||
const sampleRate = audioBuffer.sampleRate
|
||||
const length = audioBuffer.length * numberOfChannels * 2 + 44
|
||||
|
||||
const buffer = new ArrayBuffer(length)
|
||||
const view = new DataView(buffer)
|
||||
|
||||
const writeString = (offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
|
||||
writeString(0, 'RIFF')
|
||||
view.setUint32(4, length - 8, true)
|
||||
writeString(8, 'WAVE')
|
||||
writeString(12, 'fmt ')
|
||||
view.setUint32(16, 16, true)
|
||||
view.setUint16(20, 1, true)
|
||||
view.setUint16(22, numberOfChannels, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, sampleRate * numberOfChannels * 2, true)
|
||||
view.setUint16(32, numberOfChannels * 2, true)
|
||||
view.setUint16(34, 16, true)
|
||||
writeString(36, 'data')
|
||||
view.setUint32(40, length - 44, true)
|
||||
|
||||
let offset = 44
|
||||
for (let i = 0; i < audioBuffer.length; i++) {
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const sample = audioBuffer.getChannelData(channel)[i]
|
||||
const int16 = Math.max(-1, Math.min(1, sample)) * 0x7fff
|
||||
view.setInt16(offset, int16, true)
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
|
||||
await audioContext.close()
|
||||
return new Blob([buffer], { type: 'audio/wav' })
|
||||
}
|
||||
|
||||
export function useAudioRecorder(): UseAudioRecorderReturn {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
|
||||
const isSupported = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isSupported) {
|
||||
setError('您的浏览器不支持录音功能')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
audioChunksRef.current = []
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(HIGH_QUALITY_AUDIO_CONSTRAINTS)
|
||||
streamRef.current = stream
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/mp4'
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: 128000
|
||||
})
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const rawBlob = new Blob(audioChunksRef.current, { type: mimeType })
|
||||
|
||||
try {
|
||||
const wavBlob = await convertToWav(rawBlob)
|
||||
setAudioBlob(wavBlob)
|
||||
} catch (err) {
|
||||
setError('音频转换失败')
|
||||
setAudioBlob(null)
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordingDuration(0)
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 0.1)
|
||||
}, 100)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'OverconstrainedError') {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
console.warn('高质量音频约束不支持,使用浏览器默认配置')
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/mp4'
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: 128000
|
||||
})
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const rawBlob = new Blob(audioChunksRef.current, { type: mimeType })
|
||||
|
||||
try {
|
||||
const wavBlob = await convertToWav(rawBlob)
|
||||
setAudioBlob(wavBlob)
|
||||
} catch (err) {
|
||||
setError('音频转换失败')
|
||||
setAudioBlob(null)
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordingDuration(0)
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 0.1)
|
||||
}, 100)
|
||||
} catch (fallbackErr) {
|
||||
if (fallbackErr instanceof Error) {
|
||||
if (fallbackErr.name === 'NotAllowedError') {
|
||||
setError('请允许访问麦克风权限')
|
||||
} else if (fallbackErr.name === 'NotFoundError') {
|
||||
setError('未检测到麦克风设备')
|
||||
} else {
|
||||
setError('启动录音失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('请允许访问麦克风权限')
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setError('未检测到麦克风设备')
|
||||
} else {
|
||||
setError('启动录音失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isSupported])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
const clearRecording = useCallback(() => {
|
||||
setAudioBlob(null)
|
||||
setRecordingDuration(0)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordingDuration,
|
||||
audioBlob,
|
||||
error,
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecording,
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { MAX_FILE_SIZE, MIN_AUDIO_DURATION } from '@/lib/constants'
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function useAudioValidation() {
|
||||
const validateAudioFile = async (file: File): Promise<ValidationResult> => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: '文件大小不能超过 10MB' }
|
||||
}
|
||||
|
||||
const allowedTypes = ['audio/wav', 'audio/mpeg', 'audio/mp3']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return { valid: false, error: '只支持 WAV 和 MP3 格式' }
|
||||
}
|
||||
|
||||
try {
|
||||
const duration = await getAudioDuration(file)
|
||||
if (duration < MIN_AUDIO_DURATION) {
|
||||
return { valid: false, error: `音频时长必须大于 ${MIN_AUDIO_DURATION} 秒` }
|
||||
}
|
||||
return { valid: true, duration }
|
||||
} catch {
|
||||
return { valid: false, error: '无法读取音频文件元数据' }
|
||||
}
|
||||
}
|
||||
|
||||
const getAudioDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio()
|
||||
audio.onloadedmetadata = () => {
|
||||
resolve(audio.duration)
|
||||
URL.revokeObjectURL(audio.src)
|
||||
}
|
||||
audio.onerror = () => reject(new Error('无法读取音频'))
|
||||
audio.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
return { validateAudioFile }
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 209 37% 21%;
|
||||
--primary-foreground: 38 43% 86%;
|
||||
--secondary: 197 26% 67%;
|
||||
--secondary-foreground: 209 37% 21%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 38 43% 86%;
|
||||
--accent-foreground: 209 37% 21%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 14%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 209 37% 21%;
|
||||
--primary-foreground: 38 43% 86%;
|
||||
--secondary: 197 26% 67%;
|
||||
--secondary-foreground: 209 37% 21%;
|
||||
--muted: 0 0% 17.5%;
|
||||
--muted-foreground: 0 0% 65%;
|
||||
--accent: 38 43% 86%;
|
||||
--accent-foreground: 209 37% 21%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 0 0% 17.5%;
|
||||
--input: 0 0% 17.5%;
|
||||
--ring: 0 0% 84%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 300ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
[data-state="active"] {
|
||||
animation: fadeIn 300ms ease-in-out;
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import type { LoginRequest, LoginResponse, User, PasswordChangeRequest, UserPreferences } from '@/types/auth'
|
||||
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
|
||||
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
|
||||
import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user'
|
||||
import type { VoiceDesign, VoiceDesignCreate, VoiceDesignListResponse } from '@/types/voice-design'
|
||||
import { API_ENDPOINTS, LANGUAGE_NAMES, SPEAKER_DESCRIPTIONS_ZH } from '@/lib/constants'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const isTrustedApiRequest = (url?: string, baseURL?: string): boolean => {
|
||||
if (!url) return false
|
||||
if (url.startsWith('/')) return true
|
||||
|
||||
try {
|
||||
const resolvedUrl = new URL(url, baseURL || window.location.origin)
|
||||
const apiOrigin = baseURL ? new URL(baseURL, window.location.origin).origin : window.location.origin
|
||||
return resolvedUrl.origin === apiOrigin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && isTrustedApiRequest(config.url, config.baseURL || import.meta.env.VITE_API_URL)) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
interface ValidationError {
|
||||
type: string
|
||||
loc: string[]
|
||||
msg: string
|
||||
input?: any
|
||||
ctx?: any
|
||||
}
|
||||
|
||||
const FIELD_NAMES: Record<string, string> = {
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
current_password: '当前密码',
|
||||
new_password: '新密码',
|
||||
confirm_password: '确认密码',
|
||||
is_active: '激活状态',
|
||||
is_superuser: '超级管理员',
|
||||
}
|
||||
|
||||
const formatValidationErrors = (errors: ValidationError[]): string => {
|
||||
return errors.map((error) => {
|
||||
const fieldPath = error.loc.slice(1)
|
||||
const fieldName = fieldPath[fieldPath.length - 1]
|
||||
const translatedField = FIELD_NAMES[fieldName] || fieldName
|
||||
|
||||
switch (error.type) {
|
||||
case 'string_pattern_mismatch':
|
||||
if (fieldName === 'username') {
|
||||
return `${translatedField}只能包含字母、数字、下划线和连字符`
|
||||
}
|
||||
return `${translatedField}格式不正确`
|
||||
|
||||
case 'string_too_short':
|
||||
return `${translatedField}长度不能少于${error.ctx?.min_length || '指定'}个字符`
|
||||
|
||||
case 'string_too_long':
|
||||
return `${translatedField}长度不能超过${error.ctx?.max_length || '指定'}个字符`
|
||||
|
||||
case 'value_error':
|
||||
if (error.msg.includes('uppercase')) {
|
||||
return `${translatedField}必须包含至少一个大写字母`
|
||||
}
|
||||
if (error.msg.includes('lowercase')) {
|
||||
return `${translatedField}必须包含至少一个小写字母`
|
||||
}
|
||||
if (error.msg.includes('digit')) {
|
||||
return `${translatedField}必须包含至少一个数字`
|
||||
}
|
||||
if (error.msg.includes('alphanumeric')) {
|
||||
return `${translatedField}只能包含字母、数字、下划线和连字符`
|
||||
}
|
||||
return `${translatedField}: ${error.msg}`
|
||||
|
||||
case 'missing':
|
||||
return `${translatedField}为必填项`
|
||||
|
||||
case 'value_error.email':
|
||||
return `${translatedField}格式不正确`
|
||||
|
||||
default:
|
||||
return `${translatedField}: ${error.msg}`
|
||||
}
|
||||
}).join('; ')
|
||||
}
|
||||
|
||||
export const formatApiError = (error: any): string => {
|
||||
if (!error.response) {
|
||||
if (error.message === 'Network Error' || !navigator.onLine) {
|
||||
return '网络连接失败,请检查您的网络连接'
|
||||
}
|
||||
return error.message || '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
const status = error.response.status
|
||||
const data = error.response.data
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
if (data?.detail) {
|
||||
if (typeof data.detail === 'string') {
|
||||
return data.detail
|
||||
}
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail.map((err: any) => err.msg || err.message).join('; ')
|
||||
}
|
||||
}
|
||||
return '请求参数错误,请检查输入'
|
||||
|
||||
case 422:
|
||||
if (data?.detail && Array.isArray(data.detail)) {
|
||||
return formatValidationErrors(data.detail)
|
||||
}
|
||||
return '输入验证失败,请检查表单'
|
||||
|
||||
case 401:
|
||||
return '认证失败,请重新登录'
|
||||
|
||||
case 403:
|
||||
return '没有权限执行此操作'
|
||||
|
||||
case 404:
|
||||
return '请求的资源不存在'
|
||||
|
||||
case 413:
|
||||
return '上传文件过大,请选择较小的文件'
|
||||
|
||||
case 429:
|
||||
return '请求过于频繁,请稍后再试'
|
||||
|
||||
case 500:
|
||||
return '服务器错误,请稍后重试'
|
||||
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return '服务暂时不可用,请稍后重试'
|
||||
|
||||
default:
|
||||
return data?.detail || data?.message || `请求失败 (${status})`
|
||||
}
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && window.location.pathname !== '/login') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
error.message = formatApiError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', credentials.username)
|
||||
params.append('password', credentials.password)
|
||||
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.LOGIN,
|
||||
params,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>(API_ENDPOINTS.AUTH.ME)
|
||||
return response.data
|
||||
},
|
||||
|
||||
changePassword: async (data: PasswordChangeRequest): Promise<User> => {
|
||||
const response = await apiClient.post<User>(
|
||||
API_ENDPOINTS.AUTH.CHANGE_PASSWORD,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getPreferences: async (): Promise<UserPreferences> => {
|
||||
const response = await apiClient.get<UserPreferences>(API_ENDPOINTS.AUTH.PREFERENCES)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updatePreferences: async (data: UserPreferences): Promise<void> => {
|
||||
await apiClient.put(API_ENDPOINTS.AUTH.PREFERENCES, data)
|
||||
},
|
||||
|
||||
getNsfwAccess: async (): Promise<{ has_access: boolean }> => {
|
||||
const response = await apiClient.get<{ has_access: boolean }>(API_ENDPOINTS.AUTH.NSFW_ACCESS)
|
||||
return response.data
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export interface BackendStats {
|
||||
backend_type: string
|
||||
job_count: number
|
||||
char_count: number
|
||||
}
|
||||
|
||||
export interface UserUsageStats {
|
||||
user_id: number
|
||||
username: string
|
||||
email: string
|
||||
llm_prompt_tokens: number
|
||||
llm_completion_tokens: number
|
||||
tts_backends: BackendStats[]
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getLLMConfig: async (): Promise<{ base_url?: string; model?: string; has_key: boolean }> => {
|
||||
const response = await apiClient.get<{ base_url?: string; model?: string; has_key: boolean }>(
|
||||
API_ENDPOINTS.ADMIN.LLM_CONFIG
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
setLLMConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
|
||||
await apiClient.put(API_ENDPOINTS.ADMIN.LLM_CONFIG, config)
|
||||
},
|
||||
|
||||
deleteLLMConfig: async (): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.ADMIN.LLM_CONFIG)
|
||||
},
|
||||
|
||||
getGrokConfig: async (): Promise<{ base_url?: string; model?: string; has_key: boolean }> => {
|
||||
const response = await apiClient.get<{ base_url?: string; model?: string; has_key: boolean }>(
|
||||
API_ENDPOINTS.ADMIN.GROK_CONFIG
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
setGrokConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
|
||||
await apiClient.put(API_ENDPOINTS.ADMIN.GROK_CONFIG, config)
|
||||
},
|
||||
|
||||
deleteGrokConfig: async (): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.ADMIN.GROK_CONFIG)
|
||||
},
|
||||
|
||||
getUsage: async (dateFrom?: string, dateTo?: string): Promise<UserUsageStats[]> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const response = await apiClient.get<UserUsageStats[]>('/admin/usage', { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export const ttsApi = {
|
||||
getLanguages: async (): Promise<Language[]> => {
|
||||
const response = await apiClient.get<string[]>(API_ENDPOINTS.TTS.LANGUAGES)
|
||||
return response.data.map((lang) => ({
|
||||
code: lang,
|
||||
name: LANGUAGE_NAMES[lang] || lang,
|
||||
}))
|
||||
},
|
||||
|
||||
getSpeakers: async (backend?: string): Promise<Speaker[]> => {
|
||||
const params = backend ? { backend } : {}
|
||||
const response = await apiClient.get<Speaker[]>(API_ENDPOINTS.TTS.SPEAKERS, { params })
|
||||
return response.data.map((speaker) => ({
|
||||
name: speaker.name,
|
||||
description: SPEAKER_DESCRIPTIONS_ZH[speaker.name] || speaker.description,
|
||||
}))
|
||||
},
|
||||
|
||||
createCustomVoiceJob: async (data: CustomVoiceForm): Promise<JobCreateResponse> => {
|
||||
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.CUSTOM_VOICE, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createVoiceDesignJob: async (data: VoiceDesignForm): Promise<JobCreateResponse> => {
|
||||
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.VOICE_DESIGN, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createVoiceCloneJob: async (data: VoiceCloneForm): Promise<JobCreateResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('text', data.text)
|
||||
if (data.ref_audio) {
|
||||
formData.append('ref_audio', data.ref_audio)
|
||||
}
|
||||
if (data.language) {
|
||||
formData.append('language', data.language)
|
||||
}
|
||||
if (data.ref_text) {
|
||||
formData.append('ref_text', data.ref_text)
|
||||
}
|
||||
if (data.use_cache !== undefined) {
|
||||
formData.append('use_cache', String(data.use_cache))
|
||||
}
|
||||
if (data.x_vector_only_mode !== undefined) {
|
||||
formData.append('x_vector_only_mode', String(data.x_vector_only_mode))
|
||||
}
|
||||
if (data.voice_design_id !== undefined) {
|
||||
formData.append('voice_design_id', String(data.voice_design_id))
|
||||
}
|
||||
if (data.max_new_tokens !== undefined) {
|
||||
formData.append('max_new_tokens', String(data.max_new_tokens))
|
||||
}
|
||||
if (data.temperature !== undefined) {
|
||||
formData.append('temperature', String(data.temperature))
|
||||
}
|
||||
if (data.top_k !== undefined) {
|
||||
formData.append('top_k', String(data.top_k))
|
||||
}
|
||||
if (data.top_p !== undefined) {
|
||||
formData.append('top_p', String(data.top_p))
|
||||
}
|
||||
if (data.repetition_penalty !== undefined) {
|
||||
formData.append('repetition_penalty', String(data.repetition_penalty))
|
||||
}
|
||||
if (data.backend) {
|
||||
formData.append('backend', data.backend)
|
||||
}
|
||||
|
||||
const response = await apiClient.post<JobCreateResponse>(
|
||||
API_ENDPOINTS.TTS.VOICE_CLONE,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
indextts2FromDesign: async (data: { voice_design_id: number; text: string; emo_text?: string; emo_alpha?: number }): Promise<JobCreateResponse> => {
|
||||
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.INDEXTTS2_FROM_DESIGN, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
const normalizeJobType = (jobType: string): JobType => {
|
||||
const typeMap: Record<string, JobType> = {
|
||||
'custom-voice': 'custom_voice',
|
||||
'voice-design': 'voice_design',
|
||||
'voice-clone': 'voice_clone',
|
||||
}
|
||||
return typeMap[jobType] || jobType as JobType
|
||||
}
|
||||
|
||||
const normalizeJob = (job: any): Job => {
|
||||
let parameters = job.input_params || job.parameters || {}
|
||||
|
||||
if (typeof parameters === 'string') {
|
||||
try {
|
||||
parameters = JSON.parse(parameters)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse job parameters:', e)
|
||||
parameters = {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...job,
|
||||
type: normalizeJobType(job.job_type || job.type),
|
||||
parameters,
|
||||
audio_url: job.download_url || job.audio_url,
|
||||
}
|
||||
}
|
||||
|
||||
export const jobApi = {
|
||||
getJob: async (id: number): Promise<Job> => {
|
||||
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.GET(id))
|
||||
return normalizeJob(response.data)
|
||||
},
|
||||
|
||||
listJobs: async (skip = 0, limit = 100, status?: string): Promise<JobListResponse> => {
|
||||
const params: Record<string, any> = { skip, limit }
|
||||
if (status) params.status = status
|
||||
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.LIST, { params })
|
||||
return {
|
||||
...response.data,
|
||||
jobs: response.data.jobs.map(normalizeJob),
|
||||
}
|
||||
},
|
||||
|
||||
deleteJob: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.JOBS.DELETE(id))
|
||||
},
|
||||
|
||||
getAudioUrl: (id: number, audioPath?: string): string => {
|
||||
if (audioPath) {
|
||||
if (audioPath.startsWith('http')) {
|
||||
const apiBase = import.meta.env.VITE_API_URL
|
||||
if (apiBase) {
|
||||
try {
|
||||
const audioOrigin = new URL(audioPath).origin
|
||||
const apiOrigin = new URL(apiBase, window.location.origin).origin
|
||||
if (audioOrigin !== apiOrigin) {
|
||||
return API_ENDPOINTS.JOBS.AUDIO(id)
|
||||
}
|
||||
} catch {
|
||||
return API_ENDPOINTS.JOBS.AUDIO(id)
|
||||
}
|
||||
}
|
||||
if (audioPath.includes('localhost') || audioPath.includes('127.0.0.1')) {
|
||||
const url = new URL(audioPath)
|
||||
return url.pathname
|
||||
}
|
||||
return API_ENDPOINTS.JOBS.AUDIO(id)
|
||||
} else {
|
||||
return audioPath.startsWith('/') ? audioPath : `/${audioPath}`
|
||||
}
|
||||
} else {
|
||||
return API_ENDPOINTS.JOBS.AUDIO(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
listUsers: async (skip = 0, limit = 100): Promise<UserListResponse> => {
|
||||
const response = await apiClient.get<UserListResponse>(
|
||||
API_ENDPOINTS.USERS.LIST,
|
||||
{ params: { skip, limit } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getUser: async (id: number): Promise<User> => {
|
||||
const response = await apiClient.get<User>(API_ENDPOINTS.USERS.GET(id))
|
||||
return response.data
|
||||
},
|
||||
|
||||
createUser: async (data: UserCreateRequest): Promise<User> => {
|
||||
const response = await apiClient.post<User>(API_ENDPOINTS.USERS.CREATE, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateUser: async (id: number, data: UserUpdateRequest): Promise<User> => {
|
||||
const response = await apiClient.put<User>(API_ENDPOINTS.USERS.UPDATE(id), data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteUser: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.USERS.DELETE(id))
|
||||
},
|
||||
}
|
||||
|
||||
export const voiceDesignApi = {
|
||||
list: async (backend?: string): Promise<VoiceDesignListResponse> => {
|
||||
const params = backend ? { backend_type: backend } : {}
|
||||
const response = await apiClient.get<VoiceDesignListResponse>(
|
||||
API_ENDPOINTS.VOICE_DESIGNS.LIST,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
|
||||
const response = await apiClient.post<VoiceDesign>(
|
||||
API_ENDPOINTS.VOICE_DESIGNS.CREATE,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id))
|
||||
},
|
||||
|
||||
prepareAndCreate: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
|
||||
const response = await apiClient.post<VoiceDesign>(
|
||||
API_ENDPOINTS.VOICE_DESIGNS.PREPARE_AND_CREATE,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
prepareClone: async (id: number): Promise<{ message: string; cache_id: number; ref_text: string }> => {
|
||||
const response = await apiClient.post<{ message: string; cache_id: number; ref_text: string }>(
|
||||
API_ENDPOINTS.VOICE_DESIGNS.PREPARE_CLONE(id)
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -1,287 +0,0 @@
|
||||
import apiClient from '@/lib/api'
|
||||
|
||||
export interface SynopsisGenerationRequest {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
protagonist_type?: string
|
||||
tone?: string
|
||||
conflict_scale?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
violence_level?: number
|
||||
eroticism_level?: number
|
||||
}
|
||||
|
||||
export interface ScriptGenerationRequest {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
premise: string
|
||||
style?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
violence_level?: number
|
||||
eroticism_level?: number
|
||||
}
|
||||
|
||||
export interface AudiobookProject {
|
||||
id: number
|
||||
user_id: number
|
||||
title: string
|
||||
source_type: string
|
||||
status: string
|
||||
llm_model?: string
|
||||
error_message?: string
|
||||
script_config?: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
segment_total: number
|
||||
segment_done: number
|
||||
}
|
||||
|
||||
export interface AudiobookCharacter {
|
||||
id: number
|
||||
project_id: number
|
||||
name: string
|
||||
gender?: string
|
||||
description?: string
|
||||
instruct?: string
|
||||
voice_design_id?: number
|
||||
voice_design_name?: string
|
||||
voice_design_speaker?: string
|
||||
}
|
||||
|
||||
export interface AudiobookChapter {
|
||||
id: number
|
||||
project_id: number
|
||||
chapter_index: number
|
||||
title?: string
|
||||
status: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface AudiobookProjectDetail extends AudiobookProject {
|
||||
characters: AudiobookCharacter[]
|
||||
chapters: AudiobookChapter[]
|
||||
}
|
||||
|
||||
export interface AudiobookSegment {
|
||||
id: number
|
||||
project_id: number
|
||||
chapter_index: number
|
||||
segment_index: number
|
||||
character_id: number
|
||||
character_name?: string
|
||||
text: string
|
||||
emo_text?: string
|
||||
emo_alpha?: number
|
||||
audio_path?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
base_url?: string
|
||||
model?: string
|
||||
has_key: boolean
|
||||
}
|
||||
|
||||
export interface NsfwSynopsisGenerationRequest {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
protagonist_type?: string
|
||||
tone?: string
|
||||
conflict_scale?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
violence_level?: number
|
||||
eroticism_level?: number
|
||||
}
|
||||
|
||||
export interface NsfwScriptGenerationRequest {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
premise: string
|
||||
style?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
violence_level?: number
|
||||
eroticism_level?: number
|
||||
}
|
||||
|
||||
export const audiobookApi = {
|
||||
generateSynopsis: async (data: SynopsisGenerationRequest): Promise<string> => {
|
||||
const response = await apiClient.post<{ synopsis: string }>('/audiobook/projects/generate-synopsis', data)
|
||||
return response.data.synopsis
|
||||
},
|
||||
|
||||
createAIScript: async (data: ScriptGenerationRequest): Promise<AudiobookProject> => {
|
||||
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
generateNsfwSynopsis: async (data: NsfwSynopsisGenerationRequest): Promise<string> => {
|
||||
const response = await apiClient.post<{ synopsis: string }>('/audiobook/projects/generate-synopsis-nsfw', data)
|
||||
return response.data.synopsis
|
||||
},
|
||||
|
||||
createNsfwScript: async (data: NsfwScriptGenerationRequest): Promise<AudiobookProject> => {
|
||||
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script-nsfw', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createProject: async (data: {
|
||||
title: string
|
||||
source_type: string
|
||||
source_text?: string
|
||||
}): Promise<AudiobookProject> => {
|
||||
const response = await apiClient.post<AudiobookProject>('/audiobook/projects', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
uploadEpub: async (title: string, file: File): Promise<AudiobookProject> => {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('file', file)
|
||||
const response = await apiClient.post<AudiobookProject>(
|
||||
'/audiobook/projects/upload',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
listProjects: async (): Promise<AudiobookProject[]> => {
|
||||
const response = await apiClient.get<AudiobookProject[]>('/audiobook/projects')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getProject: async (id: number): Promise<AudiobookProjectDetail> => {
|
||||
const response = await apiClient.get<AudiobookProjectDetail>(`/audiobook/projects/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
analyze: async (id: number, options?: { turbo?: boolean }): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/analyze`, { turbo: options?.turbo ?? false })
|
||||
},
|
||||
|
||||
regenerateCharacters: async (id: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/regenerate-characters`)
|
||||
},
|
||||
|
||||
updateCharacter: async (
|
||||
projectId: number,
|
||||
charId: number,
|
||||
data: { name?: string; gender?: string; description?: string; instruct?: string; voice_design_id?: number }
|
||||
): Promise<AudiobookCharacter> => {
|
||||
const response = await apiClient.put<AudiobookCharacter>(
|
||||
`/audiobook/projects/${projectId}/characters/${charId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
confirmCharacters: async (id: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/confirm`)
|
||||
},
|
||||
|
||||
listChapters: async (id: number): Promise<AudiobookChapter[]> => {
|
||||
const response = await apiClient.get<AudiobookChapter[]>(`/audiobook/projects/${id}/chapters`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
parseChapter: async (projectId: number, chapterId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/chapters/${chapterId}/parse`)
|
||||
},
|
||||
|
||||
generate: async (id: number, chapterIndex?: number, force?: boolean): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/generate`, {
|
||||
chapter_index: chapterIndex ?? null,
|
||||
force: force ?? false,
|
||||
})
|
||||
},
|
||||
|
||||
getSegments: async (id: number, chapter?: number): Promise<AudiobookSegment[]> => {
|
||||
const params = chapter !== undefined ? { chapter } : {}
|
||||
const response = await apiClient.get<AudiobookSegment[]>(
|
||||
`/audiobook/projects/${id}/segments`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getDownloadUrl: (id: number, chapter?: number): string => {
|
||||
const chapterParam = chapter !== undefined ? `?chapter=${chapter}` : ''
|
||||
return `/audiobook/projects/${id}/download${chapterParam}`
|
||||
},
|
||||
|
||||
updateSegment: async (
|
||||
projectId: number,
|
||||
segmentId: number,
|
||||
data: { text: string; emo_text?: string | null; emo_alpha?: number | null }
|
||||
): Promise<AudiobookSegment> => {
|
||||
const response = await apiClient.put<AudiobookSegment>(
|
||||
`/audiobook/projects/${projectId}/segments/${segmentId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
regenerateSegment: async (projectId: number, segmentId: number): Promise<AudiobookSegment> => {
|
||||
const response = await apiClient.post<AudiobookSegment>(
|
||||
`/audiobook/projects/${projectId}/segments/${segmentId}/regenerate`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSegmentAudioUrl: (projectId: number, segmentId: number): string => {
|
||||
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
|
||||
},
|
||||
|
||||
getCharacterAudioUrl: (projectId: number, charId: number): string => {
|
||||
return `/audiobook/projects/${projectId}/characters/${charId}/audio`
|
||||
},
|
||||
|
||||
regenerateCharacterPreview: async (projectId: number, charId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/characters/${charId}/regenerate-preview`)
|
||||
},
|
||||
|
||||
parseAllChapters: async (projectId: number, onlyErrors?: boolean, force?: boolean): Promise<void> => {
|
||||
const params = new URLSearchParams()
|
||||
if (onlyErrors) params.set('only_errors', 'true')
|
||||
if (force) params.set('force', 'true')
|
||||
const qs = params.toString() ? `?${params.toString()}` : ''
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/parse-all${qs}`)
|
||||
},
|
||||
|
||||
processAll: async (projectId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/process-all`)
|
||||
},
|
||||
|
||||
cancelBatch: async (projectId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`)
|
||||
},
|
||||
|
||||
continueScript: async (id: number, additionalChapters: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/continue-script`, { additional_chapters: additionalChapters })
|
||||
},
|
||||
|
||||
deleteProject: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/audiobook/projects/${id}`)
|
||||
},
|
||||
|
||||
getLLMConfig: async (): Promise<LLMConfig> => {
|
||||
const response = await apiClient.get<LLMConfig>('/auth/llm-config')
|
||||
return response.data
|
||||
},
|
||||
|
||||
setLLMConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
|
||||
await apiClient.put('/auth/llm-config', config)
|
||||
},
|
||||
|
||||
deleteLLMConfig: async (): Promise<void> => {
|
||||
await apiClient.delete('/auth/llm-config')
|
||||
},
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/auth/token',
|
||||
ME: '/auth/me',
|
||||
CHANGE_PASSWORD: '/auth/change-password',
|
||||
PREFERENCES: '/auth/preferences',
|
||||
NSFW_ACCESS: '/auth/nsfw-access',
|
||||
},
|
||||
TTS: {
|
||||
LANGUAGES: '/tts/languages',
|
||||
SPEAKERS: '/tts/speakers',
|
||||
CUSTOM_VOICE: '/tts/custom-voice',
|
||||
VOICE_DESIGN: '/tts/voice-design',
|
||||
VOICE_CLONE: '/tts/voice-clone',
|
||||
INDEXTTS2_FROM_DESIGN: '/tts/indextts2-from-design',
|
||||
},
|
||||
JOBS: {
|
||||
LIST: '/jobs',
|
||||
GET: (id: number) => `/jobs/${id}`,
|
||||
DELETE: (id: number) => `/jobs/${id}`,
|
||||
AUDIO: (id: number) => `/jobs/${id}/download`,
|
||||
},
|
||||
USERS: {
|
||||
LIST: '/users',
|
||||
CREATE: '/users',
|
||||
GET: (id: number) => `/users/${id}`,
|
||||
UPDATE: (id: number) => `/users/${id}`,
|
||||
DELETE: (id: number) => `/users/${id}`,
|
||||
},
|
||||
ADMIN: {
|
||||
LLM_CONFIG: '/users/system/llm-config',
|
||||
GROK_CONFIG: '/users/system/grok-config',
|
||||
},
|
||||
VOICE_DESIGNS: {
|
||||
LIST: '/voice-designs',
|
||||
CREATE: '/voice-designs',
|
||||
PREPARE_AND_CREATE: '/voice-designs/prepare-and-create',
|
||||
DELETE: (id: number) => `/voice-designs/${id}`,
|
||||
PREPARE_CLONE: (id: number) => `/voice-designs/${id}/prepare-clone`,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const LANGUAGE_NAMES: Record<string, string> = {
|
||||
'Auto': '自动检测',
|
||||
'Chinese': '中文',
|
||||
'English': '英语',
|
||||
'Japanese': '日语',
|
||||
'Korean': '韩语',
|
||||
'German': '德语',
|
||||
'French': '法语',
|
||||
'Russian': '俄语',
|
||||
'Portuguese': '葡萄牙语',
|
||||
'Spanish': '西班牙语',
|
||||
'Italian': '意大利语',
|
||||
'Cantonese': '粤语',
|
||||
}
|
||||
|
||||
export const SPEAKER_DESCRIPTIONS_ZH: Record<string, string> = {
|
||||
'Vivian': '女性,专业清晰',
|
||||
'Serena': '女性,温柔温暖',
|
||||
'Uncle_Fu': '男性,成熟权威',
|
||||
'Dylan': '男性,年轻活力',
|
||||
'Eric': '男性,沉稳稳重',
|
||||
'Ryan': '男性,友好随和',
|
||||
'Aiden': '男性,低沉浑厚',
|
||||
'Ono_Anna': '女性,可爱活泼',
|
||||
'Sohee': '女性,柔和悦耳',
|
||||
}
|
||||
|
||||
export const DEFAULT_FORM_VALUES = {
|
||||
CUSTOM_VOICE: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
speaker: '',
|
||||
instruct: '',
|
||||
},
|
||||
VOICE_DESIGN: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
instruct: '',
|
||||
},
|
||||
VOICE_CLONE: {
|
||||
text: '',
|
||||
ref_audio: null,
|
||||
ref_text: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PRESET_INSTRUCTS = [
|
||||
{
|
||||
label: '开心',
|
||||
instruct: '非常开心',
|
||||
text: '今天天气真好,我们一起去公园玩吧!',
|
||||
},
|
||||
{
|
||||
label: '悲伤',
|
||||
instruct: '很悲伤,带着哭腔',
|
||||
text: '对不起,我真的尽力了,但还是让你失望了。',
|
||||
},
|
||||
{
|
||||
label: '愤怒',
|
||||
instruct: '非常愤怒,语气激烈',
|
||||
text: '你怎么能这样做!这简直太过分了!',
|
||||
},
|
||||
{
|
||||
label: '温柔关怀',
|
||||
instruct: '温柔体贴,语速平缓,音调柔和,充满关怀和安慰',
|
||||
text: '别担心,一切都会好起来的。我会一直陪在你身边。',
|
||||
},
|
||||
{
|
||||
label: '兴奋激动',
|
||||
instruct: '非常兴奋激动,语速加快,音调上扬,充满活力和热情',
|
||||
text: '太棒了!我们终于成功了!这真是太令人激动了!',
|
||||
},
|
||||
{
|
||||
label: '焦虑紧张',
|
||||
instruct: '焦虑不安的语气,语速略快,音调不稳定,带有紧张和担忧',
|
||||
text: '怎么办?时间不够了,我们来不及了,这可怎么办才好?',
|
||||
},
|
||||
{
|
||||
label: '专业播音员',
|
||||
instruct: '专业新闻播音员。语速:标准播音语速,吐字清晰。情绪:沉稳专业,不带个人感情色彩。语调:平直中略有起伏,重点词汇加重。性格特征:严谨、客观、权威。',
|
||||
text: '据新华社报道,我国航天事业取得重大突破,神舟系列飞船成功完成载人飞行任务。',
|
||||
},
|
||||
{
|
||||
label: '温暖导师',
|
||||
instruct: '温暖导师。语速:不急不缓,娓娓道来。音调:平稳中带有鼓励性上扬。情绪:关怀、耐心、鼓励。性格:善解人意,循循善诱,充满正能量。',
|
||||
text: '每个人都有自己的节奏,不要着急。慢慢来,你一定能找到属于自己的那条路。',
|
||||
},
|
||||
{
|
||||
label: '活力少年',
|
||||
instruct: '充满活力。语速:偏快,吐字利落。情绪:开朗乐观,精力充沛。语调:跳跃感强,抑扬顿挫。性格:外向、自信、热情,充满青春气息。',
|
||||
text: '哇,这个游戏太酷了!咱们组队一起玩吧,我保证带你们飞!',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const PRESET_VOICE_DESIGNS = [
|
||||
{
|
||||
label: '甜美少女',
|
||||
instruct: '年轻女性,音色清甜明亮,略带少女的娇俏感。音高偏高,语调活泼富于变化。语速适中,吐字清晰。情绪愉悦轻松,充满青春活力。适合场景:客服语音、语音助手、娱乐内容。',
|
||||
text: '您好,很高兴为您服务!请问有什么可以帮助您的吗?',
|
||||
},
|
||||
{
|
||||
label: '成熟女性',
|
||||
instruct: '成熟知性的女性声音,音色温润饱满,带有职业女性的干练气质。音高中等,音域稳定。语速适中偏快,条理清晰。情绪从容自信,传递专业可靠的感觉。',
|
||||
text: '根据最新的市场分析报告,本季度业绩呈现稳步增长态势,各项指标均达到预期目标。',
|
||||
},
|
||||
{
|
||||
label: '磁性男声',
|
||||
instruct: '中低音男性声音,音色深沉磁性,富有感染力。语速偏慢,节奏沉稳。音量适中,声音浑厚有力。适合情感类、故事讲述、品牌宣传等场景。',
|
||||
text: '夜深了,城市的灯火依然璀璨。每一盏灯下,都有一个关于梦想的故事。',
|
||||
},
|
||||
{
|
||||
label: '活力青年',
|
||||
instruct: '充满活力的年轻男性,音色明亮清晰,带有青春朝气。语速较快,节奏感强。情绪热情积极,富有感染力。适合运动、游戏、娱乐等场景。',
|
||||
text: '兄弟们,准备好了吗?今天我们要挑战全新的副本,冲冲冲!',
|
||||
},
|
||||
{
|
||||
label: '权威专家',
|
||||
instruct: '中年男性专家形象,音色沉稳权威,声音浑厚有力。语速适中,吐字清晰标准。情绪严肃专业,传递信任感和专业度。适合学术讲座、知识科普、正式场合。',
|
||||
text: '从历史发展的角度来看,科技创新始终是推动社会进步的核心动力。',
|
||||
},
|
||||
{
|
||||
label: '温柔妈妈',
|
||||
instruct: '温柔慈爱的中年女性,音色柔和温暖,充满母性关怀。语速舒缓,音调平和安抚。情绪温暖体贴,给人安全感。适合儿童内容、情感陪伴、睡前故事。',
|
||||
text: '宝贝,该睡觉了。妈妈给你讲个故事,从前有一只小兔子,它住在森林里...',
|
||||
},
|
||||
{
|
||||
label: '播音主持',
|
||||
instruct: '专业播音主持人声音,音色饱满圆润,标准普通话发音。音高适中,音域宽广。语速标准,节奏把控精准。情绪专业沉稳,字正腔圆。适合新闻播报、节目主持、正式朗读。',
|
||||
text: '各位听众朋友大家好,欢迎收听今天的节目。接下来为您带来今日要闻。',
|
||||
},
|
||||
{
|
||||
label: '俏皮少女',
|
||||
instruct: '俏皮可爱的少女音色,声音轻快灵动,带有少女特有的活泼感。音调偏高且富于变化,语气中带有撒娇和卖萌的元素。语速时快时慢,吐字清晰但带有可爱的语气词。',
|
||||
text: '哎呀,人家不是故意的嘛~你就原谅我一次好不好?拜托拜托啦~',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const PRESET_REF_TEXTS = [
|
||||
{
|
||||
label: '自然生活',
|
||||
text: '在这个快节奏的世界里,我们总是在赶路,却忘了停下来听听内心的声音。其实,生活不仅仅是眼前的忙碌,还有远方的诗意和偶然发现的小确幸。希望这段录音,能像午后的微风一样,带给你一点点温柔和力量。无论未来如何变化,请记得保持对生活的热爱,去拥抱每一个灿烂的明天。',
|
||||
},
|
||||
{
|
||||
label: '专业正式',
|
||||
text: '科技的进步让我们能够跨越时空的界限,用数字化的方式延续情感与记忆。语音克隆不仅是精密的代码逻辑,更是连接人类与未来智能的纽带。通过深度学习与神经网络的不断演进,每一个细微的语调起伏,都能被精准地捕捉。让我们共同见证,技术如何赋予声音更具生命力的表达。',
|
||||
},
|
||||
{
|
||||
label: '文学叙事',
|
||||
text: '春天的风拂过柳梢,带着泥土的芬芳和花开的消息。你是否也曾期待过,在某个街角的转弯处,遇见那个久违的自己?无论是高亢的欢笑,还是低沉的呢喃,每一种声音都是独一无二的生命印记。让我们在此刻记录当下,让回忆在流淌的声音里,化作永恒的旋律。',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const POLL_INTERVAL = 2000
|
||||
|
||||
export const TIMEOUT_WARNING = 30000
|
||||
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
export const MIN_AUDIO_DURATION = 3
|
||||
|
||||
export const ADVANCED_PARAMS_INFO = {
|
||||
max_new_tokens: {
|
||||
label: '最大生成长度',
|
||||
description: '控制生成音频的最大长度。值越大,可生成的音频越长,但处理时间也会增加',
|
||||
tooltip: '建议值: 2048-4096。超过 8000 可能导致生成时间过长',
|
||||
},
|
||||
temperature: {
|
||||
label: '温度',
|
||||
description: '控制生成的随机性。值越高生成越随机多样,值越低越稳定一致',
|
||||
tooltip: '推荐范围: 0.1-0.5 (稳定) | 0.6-1.0 (多样) | >1.0 (创意)',
|
||||
},
|
||||
top_k: {
|
||||
label: 'Top K',
|
||||
description: '采样时只考虑概率最高的 K 个候选。值越小生成越确定,越大越多样',
|
||||
tooltip: '常用值: 20-50。过小可能导致重复,过大可能不连贯',
|
||||
},
|
||||
top_p: {
|
||||
label: 'Top P (核采样)',
|
||||
description: '累积概率阈值,只从累积概率达到 P 的候选中采样。控制输出多样性',
|
||||
tooltip: '推荐值: 0.7-0.9。0.9 更自然多变,0.7 更稳定可控',
|
||||
},
|
||||
repetition_penalty: {
|
||||
label: '重复惩罚',
|
||||
description: '惩罚重复内容的生成。值越大越避免重复,但过大可能影响自然度',
|
||||
tooltip: '建议范围: 1.0-1.2。1.0 表示无惩罚,1.05 适合大多数场景',
|
||||
},
|
||||
} as const
|
||||
@@ -1,148 +0,0 @@
|
||||
type Language = 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'ko-KR'
|
||||
|
||||
interface FontConfig {
|
||||
name: string
|
||||
file: string
|
||||
unicodeRange?: string
|
||||
}
|
||||
|
||||
const fontConfigs: Record<Language, FontConfig[]> = {
|
||||
'zh-CN': [
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-sc-regular.woff2',
|
||||
unicodeRange: 'U+4E00-9FFF, U+3400-4DBF, U+20000-2A6DF, U+2A700-2B73F, U+2B740-2B81F, U+2B820-2CEAF, U+F900-FAFF, U+2F800-2FA1F',
|
||||
},
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
},
|
||||
],
|
||||
'zh-TW': [
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-tc-regular.woff2',
|
||||
unicodeRange: 'U+4E00-9FFF, U+3400-4DBF, U+F900-FAFF',
|
||||
},
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-sc-regular.woff2',
|
||||
unicodeRange: 'U+20000-2A6DF, U+2A700-2B73F, U+2B740-2B81F, U+2B820-2CEAF, U+2F800-2FA1F',
|
||||
},
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
},
|
||||
],
|
||||
'en-US': [
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
},
|
||||
],
|
||||
'ja-JP': [
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-jp-regular.woff2',
|
||||
unicodeRange: 'U+3000-30FF, U+4E00-9FFF, U+FF00-FFEF',
|
||||
},
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
},
|
||||
],
|
||||
'ko-KR': [
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-kr-regular.woff2',
|
||||
unicodeRange: 'U+AC00-D7AF, U+1100-11FF, U+3130-318F',
|
||||
},
|
||||
{
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>()
|
||||
|
||||
function createFontFace(config: FontConfig): FontFace {
|
||||
const descriptors: FontFaceDescriptors = {
|
||||
style: 'normal',
|
||||
weight: '400',
|
||||
display: 'swap',
|
||||
}
|
||||
|
||||
if (config.unicodeRange) {
|
||||
descriptors.unicodeRange = config.unicodeRange
|
||||
}
|
||||
|
||||
return new FontFace(config.name, `url(${config.file}) format('woff2')`, descriptors)
|
||||
}
|
||||
|
||||
export function loadFontsForLanguage(language: Language): void {
|
||||
const configs = fontConfigs[language]
|
||||
if (!configs) return
|
||||
|
||||
configs.forEach((config) => {
|
||||
const fontKey = `${config.name}-${config.file}`
|
||||
|
||||
if (loadedFonts.has(fontKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fontFace = createFontFace(config)
|
||||
document.fonts.add(fontFace)
|
||||
loadedFonts.add(fontKey)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to register font ${config.file}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function detectBrowserLanguage(): Language {
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
|
||||
if (browserLang.startsWith('zh-tw') || browserLang.startsWith('zh-hk') || browserLang.startsWith('zh-mo')) {
|
||||
return 'zh-TW'
|
||||
}
|
||||
if (browserLang.startsWith('zh')) {
|
||||
return 'zh-CN'
|
||||
}
|
||||
if (browserLang.startsWith('ja')) {
|
||||
return 'ja-JP'
|
||||
}
|
||||
if (browserLang.startsWith('ko')) {
|
||||
return 'ko-KR'
|
||||
}
|
||||
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
export function preloadBaseFont(): void {
|
||||
const baseConfig: FontConfig = {
|
||||
name: 'Noto Serif',
|
||||
file: '/fonts/noto-serif-latin-regular.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
|
||||
}
|
||||
|
||||
const fontKey = `${baseConfig.name}-${baseConfig.file}`
|
||||
|
||||
if (loadedFonts.has(fontKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fontFace = createFontFace(baseConfig)
|
||||
document.fonts.add(fontFace)
|
||||
loadedFonts.add(fontKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to register base font:', error)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSecs < 60) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function getAudioDuration(file: File): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio()
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
resolve(Math.floor(audio.duration))
|
||||
})
|
||||
audio.addEventListener('error', () => {
|
||||
reject(new Error('Failed to load audio file'))
|
||||
})
|
||||
audio.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"title": "Audiobook Generation",
|
||||
"llmConfig": "LLM Config",
|
||||
"newProject": "New Project",
|
||||
"loading": "Loading...",
|
||||
"noProjects": "No audiobook projects yet",
|
||||
"noProjectsHint": "Click \"New Project\" to get started",
|
||||
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"analyzing": "Analyzing",
|
||||
"characters_ready": "Awaiting Character Review",
|
||||
"parsing": "Extracting Dialogue",
|
||||
"ready": "Ready",
|
||||
"processing": "Processing",
|
||||
"generating": "Generating",
|
||||
"done": "Done",
|
||||
"error": "Error",
|
||||
"turboActive": "⚡ Turbo"
|
||||
},
|
||||
|
||||
"stepHints": {
|
||||
"pending": "Step 1: Click \"Analyze\" — the LLM will automatically extract the character list",
|
||||
"analyzing": "Step 1: LLM is extracting characters, please wait...",
|
||||
"characters_ready": "Step 2: Review character info, then click \"Confirm Characters · Identify Chapters\"",
|
||||
"ready": "Step 3: Parse chapters one by one (LLM); parsed chapters can generate audio immediately",
|
||||
"generating": "Step 4: Synthesizing audio — completed segments can be played immediately"
|
||||
},
|
||||
|
||||
"llmConfigPanel": {
|
||||
"title": "LLM Configuration",
|
||||
"current": "Current: {{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||
"hasKey": "API key set",
|
||||
"noKey": "No API key",
|
||||
"notSet": "Not set",
|
||||
"saving": "Saving...",
|
||||
"save": "Save Config",
|
||||
"savedSuccess": "LLM config saved",
|
||||
"incompleteError": "Please fill in all LLM configuration fields"
|
||||
},
|
||||
|
||||
"createPanel": {
|
||||
"title": "New Audiobook Project",
|
||||
"titlePlaceholder": "Title",
|
||||
"pasteText": "Paste Text",
|
||||
"uploadEpub": "Upload EPUB",
|
||||
"textPlaceholder": "Paste novel text here...",
|
||||
"creating": "Creating...",
|
||||
"create": "Create Project",
|
||||
"createdSuccess": "Project created",
|
||||
"titleRequired": "Please enter a title",
|
||||
"textRequired": "Please enter text content",
|
||||
"epubRequired": "Please select an EPUB file"
|
||||
},
|
||||
|
||||
"projectCard": {
|
||||
"analyze": "Analyze",
|
||||
"reanalyze": "Re-analyze",
|
||||
"reanalyzeConfirm": "Re-analyzing will clear all character and chapter data. Continue?",
|
||||
"analyzeStarted": "Analysis started",
|
||||
"generateAll": "Generate Full Book",
|
||||
"processAll": "⚡ Process All",
|
||||
"downloadAll": "Download Full Book",
|
||||
"deleteConfirm": "Delete project \"{{title}}\" and all its audio?",
|
||||
"deleteSuccess": "Project deleted",
|
||||
"allDoneToast": "\"{{title}}\" — all audio generation complete!",
|
||||
"segmentsProgress": "{{done}} / {{total}} segments done",
|
||||
"chaptersProgress": "Chapters parsed: {{parsed}} / {{total}}",
|
||||
"chaptersParsing": "{{count}} parsing",
|
||||
"chaptersError": "{{count}} error",
|
||||
"cancelParsing": "✖ Cancel Parsing",
|
||||
"cancelGenerating": "✖ Cancel Generating",
|
||||
"retryFailed": "Retry Failed",
|
||||
"cancelledToast": "Cancel signal sent. Running tasks will finish then stop.",
|
||||
|
||||
"characters": {
|
||||
"title": "Characters ({{count}})",
|
||||
"namePlaceholder": "Character name",
|
||||
"genderPlaceholder": "Gender (not set)",
|
||||
"genderMale": "Male",
|
||||
"genderFemale": "Female",
|
||||
"genderUnknown": "Unknown",
|
||||
"instructPlaceholder": "Voice description (for TTS)",
|
||||
"descPlaceholder": "Character description",
|
||||
"voiceDesign": "Voice #{{id}}",
|
||||
"noVoice": "Unassigned",
|
||||
"editTitle": "Edit Character: {{name}}",
|
||||
"savedSuccess": "Character saved",
|
||||
"regeneratingPreview": "Regenerating...",
|
||||
"regeneratePreview": "Regenerate Preview",
|
||||
"regenerateAll": "Regenerate All Previews",
|
||||
"regenerateAllDone": "All previews regenerated",
|
||||
"previewNotReady": "Collecting preview..."
|
||||
},
|
||||
|
||||
"confirm": {
|
||||
"button": "Confirm Characters · Identify Chapters",
|
||||
"generateScript": "Confirm Characters & Generate Script",
|
||||
"loading": "Identifying...",
|
||||
"chaptersRecognized": "Chapters identified"
|
||||
},
|
||||
|
||||
"chapters": {
|
||||
"title": "Chapters ({{count}} total)",
|
||||
"processAll": "⚡ Process All",
|
||||
"parseAll": "Batch Extract",
|
||||
"parseAllAI": "Batch Rewrite",
|
||||
"generateAll": "Batch Synthesize",
|
||||
"defaultTitle": "Chapter {{index}}",
|
||||
"parse": "Extract Dialogue",
|
||||
"parseAI": "Rewrite Chapter",
|
||||
"parsing": "Extracting",
|
||||
"parseStarted": "Extracting \"{{title}}\" started",
|
||||
"parseStartedDefault": "Chapter extraction started",
|
||||
"reparse": "Re-extract",
|
||||
"reparseAI": "Rewrite",
|
||||
"generate": "Synthesize",
|
||||
"generateStarted": "Chapter {{index}} synthesis started",
|
||||
"generateAllStarted": "Full book synthesis started",
|
||||
"processAllStarted": "All tasks triggered",
|
||||
"parseAllStarted": "Batch extraction started",
|
||||
"doneBadge": "{{count}} segments done",
|
||||
"segmentProgress": "{{done}}/{{total}} segments",
|
||||
"continueScript": "Continue Script",
|
||||
"continueScriptStarted": "Continue script generation started"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "Continue AI Script",
|
||||
"label": "Additional chapters (1-20)",
|
||||
"start": "Start",
|
||||
"starting": "Generating..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "Error",
|
||||
"unknownCharacter": "?",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"regenerate": "Regenerate",
|
||||
"regenerating": "Generating...",
|
||||
"savedSuccess": "Segment saved",
|
||||
"emotion": "Emotion",
|
||||
"noEmotion": "No emotion",
|
||||
"intensity": "Intensity"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
"play": "Play In Order ({{count}} segments)",
|
||||
"stop": "Stop",
|
||||
"progress": "{{current}} / {{total}}",
|
||||
"loading": "Loading..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"welcome": "Welcome to Qwen TTS",
|
||||
"loginPrompt": "Please login to continue",
|
||||
"loginSuccess": "Login successful",
|
||||
"loginFailed": "Login failed",
|
||||
"loginFailedCheckCredentials": "Login failed, please check username and password",
|
||||
"logoutSuccess": "Logged out successfully",
|
||||
"unauthorized": "Unauthorized, please login",
|
||||
"sessionExpired": "Session expired, please login again",
|
||||
"noPermission": "You don't have permission to perform this action",
|
||||
"adminOnly": "This feature is only available to administrators",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"validation": {
|
||||
"usernameMinLength": "Username must be at least {{min}} characters",
|
||||
"usernameMaxLength": "Username cannot exceed {{max}} characters",
|
||||
"passwordMinLength": "Password must be at least {{min}} characters",
|
||||
"apiKeyRequired": "Please enter API key"
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"submit": "Submit",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"clear": "Clear",
|
||||
"reset": "Reset",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"view": "View",
|
||||
"details": "Details",
|
||||
"actions": "Actions",
|
||||
"generatingAudio": "Generating audio, please wait...",
|
||||
"generationTakingLong": "Generation is taking longer, please be patient...",
|
||||
"waitedSeconds": "Waited {{seconds}} seconds",
|
||||
"loadingAudio": "Loading...",
|
||||
"failedToLoadAudio": "Failed to load audio"
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"languages": {
|
||||
"Auto": "Auto Detect",
|
||||
"Chinese": "Chinese",
|
||||
"English": "English",
|
||||
"Japanese": "Japanese",
|
||||
"Korean": "Korean",
|
||||
"German": "German",
|
||||
"French": "French",
|
||||
"Russian": "Russian",
|
||||
"Portuguese": "Portuguese",
|
||||
"Spanish": "Spanish",
|
||||
"Italian": "Italian",
|
||||
"Cantonese": "Cantonese",
|
||||
"zh": "Chinese",
|
||||
"en": "English",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"yue": "Cantonese"
|
||||
},
|
||||
"speakers": {
|
||||
"Vivian": "Female, professional and clear",
|
||||
"Serena": "Female, gentle and warm",
|
||||
"Aria": "Female, lively and cheerful",
|
||||
"Emma": "Female, mature and steady",
|
||||
"Sophie": "Female, elegant and intelligent",
|
||||
"Isabella": "Female, graceful and amiable",
|
||||
"Ava": "Female, young and trendy",
|
||||
"Oliver": "Male, magnetic and calm",
|
||||
"Lucas": "Male, sunny and cheerful",
|
||||
"Ethan": "Male, professional and grand",
|
||||
"Noah": "Male, gentle and friendly",
|
||||
"Liam": "Male, young and energetic"
|
||||
},
|
||||
"uiLanguages": {
|
||||
"zh-CN": "Simplified Chinese",
|
||||
"zh-TW": "Traditional Chinese",
|
||||
"en-US": "English",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean"
|
||||
},
|
||||
"uiLanguagesShort": {
|
||||
"zh-CN": "ZH-CN",
|
||||
"zh-TW": "ZH-TW",
|
||||
"en-US": "EN",
|
||||
"ja-JP": "JA",
|
||||
"ko-KR": "KO"
|
||||
},
|
||||
"presetInstructs": [
|
||||
{
|
||||
"label": "Happy",
|
||||
"instruct": "very happy",
|
||||
"text": "The weather is so nice today, let's go to the park together!"
|
||||
},
|
||||
{
|
||||
"label": "Sad",
|
||||
"instruct": "very sad, with a crying tone",
|
||||
"text": "I'm sorry, I really tried my best, but I still let you down."
|
||||
},
|
||||
{
|
||||
"label": "Angry",
|
||||
"instruct": "very angry, with intense tone",
|
||||
"text": "How could you do this! This is absolutely unacceptable!"
|
||||
},
|
||||
{
|
||||
"label": "Gentle Care",
|
||||
"instruct": "gentle and caring, slow pace, soft tone, full of care and comfort",
|
||||
"text": "Don't worry, everything will be fine. I'll always be here with you."
|
||||
},
|
||||
{
|
||||
"label": "Excited",
|
||||
"instruct": "very excited, faster pace, rising tone, full of energy and enthusiasm",
|
||||
"text": "Awesome! We finally made it! This is so exciting!"
|
||||
},
|
||||
{
|
||||
"label": "Anxious",
|
||||
"instruct": "anxious tone, slightly faster pace, unstable tone, with tension and worry",
|
||||
"text": "What should we do? We're running out of time, we won't make it, what can we do?"
|
||||
},
|
||||
{
|
||||
"label": "Professional Broadcaster",
|
||||
"instruct": "Professional news broadcaster. Pace: standard broadcasting speed, clear articulation. Emotion: calm and professional, without personal emotion. Tone: mostly flat with slight variations, emphasis on key words. Character: rigorous, objective, authoritative.",
|
||||
"text": "According to Reuters, our space program has achieved a major breakthrough, with the successful completion of manned space missions."
|
||||
},
|
||||
{
|
||||
"label": "Warm Mentor",
|
||||
"instruct": "Warm mentor. Pace: unhurried, speaking slowly. Tone: stable with encouraging rises. Emotion: caring, patient, encouraging. Character: understanding, guiding, full of positive energy.",
|
||||
"text": "Everyone has their own pace, don't rush. Take your time, you will definitely find your own path."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Full of energy. Pace: fast, crisp articulation. Emotion: cheerful and optimistic, energetic. Tone: strong sense of rhythm, cadence. Character: outgoing, confident, enthusiastic, full of youthful spirit.",
|
||||
"text": "Wow, this game is so cool! Let's team up and play together, I promise I'll carry you!"
|
||||
}
|
||||
],
|
||||
"presetVoiceDesigns": [
|
||||
{
|
||||
"label": "Sweet Girl",
|
||||
"instruct": "Young female, sweet and bright voice, with a touch of girlish charm. High pitch, lively and varied intonation. Moderate pace, clear articulation. Cheerful and relaxed emotion, full of youthful energy. Suitable for: customer service, voice assistant, entertainment content.",
|
||||
"text": "Hello, I'm happy to help you! How may I assist you today?"
|
||||
},
|
||||
{
|
||||
"label": "Mature Woman",
|
||||
"instruct": "Mature and intellectual female voice, warm and full tone, with professional woman's capable temperament. Medium pitch, stable range. Moderate to fast pace, clear and organized. Calm and confident emotion, conveying professionalism and reliability.",
|
||||
"text": "According to the latest market analysis report, this quarter's performance shows steady growth, with all indicators meeting expected targets."
|
||||
},
|
||||
{
|
||||
"label": "Magnetic Male",
|
||||
"instruct": "Mid-low male voice, deep and magnetic tone, very appealing. Slow pace, steady rhythm. Moderate volume, thick and powerful voice. Suitable for emotional content, storytelling, brand promotion.",
|
||||
"text": "Night falls, yet the city lights remain brilliant. Under each light, there's a story about dreams."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Energetic young male, bright and clear tone, with youthful vigor. Fast pace, strong sense of rhythm. Enthusiastic and positive emotion, very appealing. Suitable for sports, gaming, entertainment.",
|
||||
"text": "Brothers, are you ready? Today we're going to challenge the new dungeon, let's go!"
|
||||
},
|
||||
{
|
||||
"label": "Authority Expert",
|
||||
"instruct": "Middle-aged male expert image, calm and authoritative tone, thick and powerful voice. Moderate pace, clear and standard articulation. Serious and professional emotion, conveying trust and expertise. Suitable for academic lectures, knowledge popularization, formal occasions.",
|
||||
"text": "From a historical development perspective, technological innovation has always been the core driving force for social progress."
|
||||
},
|
||||
{
|
||||
"label": "Gentle Mother",
|
||||
"instruct": "Gentle and loving middle-aged female, soft and warm tone, full of maternal care. Slow pace, calm and soothing tone. Warm and caring emotion, giving a sense of security. Suitable for children's content, emotional companionship, bedtime stories.",
|
||||
"text": "Sweetie, it's time to sleep. Mom will tell you a story. Once upon a time, there was a little rabbit who lived in the forest..."
|
||||
},
|
||||
{
|
||||
"label": "Broadcasting Host",
|
||||
"instruct": "Professional broadcasting host voice, full and round tone, standard pronunciation. Medium pitch, wide range. Standard pace, precise rhythm control. Professional and calm emotion, clear articulation. Suitable for news broadcasting, program hosting, formal reading.",
|
||||
"text": "Hello dear listeners, welcome to today's program. Next, we bring you today's news."
|
||||
},
|
||||
{
|
||||
"label": "Playful Girl",
|
||||
"instruct": "Playful and cute girl voice, light and lively sound, with unique girlish liveliness. High and varied tone, with coquettish and cute elements. Varying pace, clear articulation with cute interjections.",
|
||||
"text": "Oh no, I didn't mean to~ Can you forgive me this time? Please please~"
|
||||
}
|
||||
],
|
||||
"presetRefTexts": [
|
||||
{
|
||||
"label": "Natural Life",
|
||||
"text": "In this fast-paced world, we're always rushing forward, forgetting to pause and listen to our inner voice. Life is not just about the busyness before us, but also the poetry in the distance and the little moments of happiness we discover. May this recording bring you a touch of gentleness and strength, like an afternoon breeze. No matter how the future changes, remember to keep your love for life and embrace every bright tomorrow."
|
||||
},
|
||||
{
|
||||
"label": "Professional Formal",
|
||||
"text": "Technological progress allows us to transcend the boundaries of time and space, continuing emotions and memories through digitalization. Voice cloning is not only precise code logic, but also a bridge connecting humanity with future intelligence. Through the continuous evolution of deep learning and neural networks, every subtle intonation can be accurately captured. Let us witness together how technology gives voice a more vital expression."
|
||||
},
|
||||
{
|
||||
"label": "Literary Narrative",
|
||||
"text": "The spring breeze brushes the willow tips, carrying the fragrance of earth and news of blooming flowers. Have you ever anticipated meeting your long-lost self at some street corner? Whether it's hearty laughter or low whispers, each voice is a unique mark of life. Let us record this moment, let memories flow in the sound, becoming an eternal melody."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"http": {
|
||||
"400": "Invalid request parameters",
|
||||
"401": "Unauthorized, please login",
|
||||
"403": "Access forbidden",
|
||||
"404": "Requested resource not found",
|
||||
"500": "Internal server error",
|
||||
"502": "Gateway error",
|
||||
"503": "Service temporarily unavailable",
|
||||
"default": "Request failed, please try again later"
|
||||
},
|
||||
"fieldNames": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"text": "Text",
|
||||
"language": "Language",
|
||||
"speaker": "Speaker",
|
||||
"instruct": "Emotion instruction",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"audio_file": "Audio file",
|
||||
"reference_audio": "Reference audio",
|
||||
"reference_text": "Reference text",
|
||||
"api_key": "API key",
|
||||
"backend": "Backend service"
|
||||
},
|
||||
"validation": {
|
||||
"required": "{{field}} is required",
|
||||
"minLength": "{{field}} must be at least {{min}} characters",
|
||||
"maxLength": "{{field}} cannot exceed {{max}} characters",
|
||||
"invalid": "{{field}} format is invalid",
|
||||
"notFound": "{{field}} not found",
|
||||
"alreadyExists": "{{field}} already exists",
|
||||
"uploadFailed": "{{field}} upload failed",
|
||||
"fileTooLarge": "File size cannot exceed {{size}}MB",
|
||||
"invalidFileType": "Unsupported file type"
|
||||
},
|
||||
"networkError": "Network connection failed, please check your network",
|
||||
"unknownError": "Unknown error",
|
||||
"operationFailed": "Operation failed",
|
||||
"tryAgain": "Please try again"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import common from './common.json'
|
||||
import nav from './nav.json'
|
||||
import auth from './auth.json'
|
||||
import tts from './tts.json'
|
||||
import voice from './voice.json'
|
||||
import job from './job.json'
|
||||
import settings from './settings.json'
|
||||
import user from './user.json'
|
||||
import errors from './errors.json'
|
||||
import constants from './constants.json'
|
||||
import onboarding from './onboarding.json'
|
||||
import audiobook from './audiobook.json'
|
||||
|
||||
export default {
|
||||
common,
|
||||
nav,
|
||||
auth,
|
||||
tts,
|
||||
voice,
|
||||
job,
|
||||
settings,
|
||||
user,
|
||||
errors,
|
||||
constants,
|
||||
onboarding,
|
||||
audiobook,
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"jobHistory": "Job History",
|
||||
"jobList": "Job List",
|
||||
"jobDetails": "Job Details",
|
||||
"jobId": "Job ID",
|
||||
"status": "Status",
|
||||
"createdAt": "Created At",
|
||||
"completedAt": "Completed At",
|
||||
"duration": "Duration",
|
||||
"statusPending": "Pending",
|
||||
"statusProcessing": "Processing",
|
||||
"statusCompleted": "Completed",
|
||||
"statusFailed": "Failed",
|
||||
"noJobs": "No jobs",
|
||||
"viewJob": "View Job",
|
||||
"deleteJob": "Delete Job",
|
||||
"deleteJobConfirm": "Are you sure you want to delete this job?",
|
||||
"jobDeleted": "Job deleted",
|
||||
"refreshJobs": "Refresh Jobs",
|
||||
"inputText": "Input Text",
|
||||
"parameters": "Parameters",
|
||||
"result": "Result",
|
||||
"errorMessage": "Error Message",
|
||||
"downloadResult": "Download Result",
|
||||
"retryJob": "Retry",
|
||||
"cancelJob": "Cancel Job",
|
||||
"historyTitle": "History",
|
||||
"historyCount": "{{count}} records",
|
||||
"retry": "Retry",
|
||||
"noHistory": "No history",
|
||||
"historyDescription": "Records will appear here after generating speech",
|
||||
"detailsDescription": "View task details and generation results",
|
||||
"basicInfo": "Basic Information",
|
||||
"speaker": "Speaker: ",
|
||||
"language": "Language: ",
|
||||
"autoDetect": "Auto Detect",
|
||||
"fastMode": "Fast Mode: ",
|
||||
"useCache": "Use Cache: ",
|
||||
"synthesisText": "Synthesis Text",
|
||||
"notSet": "Not set",
|
||||
"voiceDescription": "Voice Description",
|
||||
"emotionGuidance": "Emotion Guidance",
|
||||
"referenceText": "Reference Text",
|
||||
"notProvided": "Not provided",
|
||||
"advancedParameters": "Advanced Parameters",
|
||||
"maxNewTokens": "Max New Tokens: ",
|
||||
"temperature": "Temperature: ",
|
||||
"topK": "Top K: ",
|
||||
"topP": "Top P: ",
|
||||
"repetitionPenalty": "Repetition Penalty: ",
|
||||
"audioPlayback": "Audio Playback",
|
||||
"typeCustomVoice": "Custom Voice",
|
||||
"typeVoiceDesign": "Voice Design",
|
||||
"typeVoiceClone": "Voice Clone"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"settings": "Settings",
|
||||
"userManagement": "User Management",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"toggleTheme": "Toggle Theme",
|
||||
"changeLanguage": "Change Language",
|
||||
"customVoiceTab": "Custom",
|
||||
"voiceDesignTab": "Create Voice",
|
||||
"voiceCloneTab": "Clone",
|
||||
"voiceManagement": "Voice Management"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"welcome": "Welcome to Qwen3 TTS",
|
||||
"localModel": "Local Model",
|
||||
"localModelDescription": "Free to use local Qwen3-TTS model",
|
||||
"localModelNoPermission": "No local model permission, please contact administrator",
|
||||
"skipConfig": "Skip Configuration",
|
||||
"back": "Back",
|
||||
"skipSuccess": "Configuration skipped, using local mode by default",
|
||||
"operationFailed": "Operation failed, please retry",
|
||||
"configComplete": "Configuration complete, using local mode by default",
|
||||
"saveFailed": "Failed to save configuration, please retry"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"description": "Manage your account settings and preferences",
|
||||
"language": "Interface Language",
|
||||
"languageDescription": "Select interface display language",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"accountInfo": "Account Information",
|
||||
"accountInfoDescription": "Your account basic information",
|
||||
"email": "Email",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChangeSuccess": "Password changed successfully",
|
||||
"passwordChangeFailed": "Password change failed",
|
||||
"grokConfig": "Grok-4 Config (NSFW Mode)",
|
||||
"grokConfigDescription": "Configure Grok API for NSFW script generation, applies to users with NSFW permission",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "Model (default: grok-4)"
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"customVoice": "Custom Voice",
|
||||
"voiceDesign": "Voice Design",
|
||||
"voiceClone": "Voice Clone",
|
||||
"text": "Text",
|
||||
"textPlaceholder": "Enter text to synthesize...",
|
||||
"language": "Language",
|
||||
"speaker": "Speaker",
|
||||
"instruct": "Emotion Instruction",
|
||||
"instructPlaceholder": "e.g., very happy, a bit sad...",
|
||||
"customSpeaker": "Custom Speaker",
|
||||
"customSpeakerPlaceholder": "e.g., Vivian, Alice...",
|
||||
"presetInstructs": "Preset Emotions",
|
||||
"generate": "Generate Speech",
|
||||
"generating": "Generating...",
|
||||
"generationSuccess": "Speech generated successfully",
|
||||
"generationFailed": "Speech generation failed",
|
||||
"audioPlayer": "Audio Player",
|
||||
"noAudio": "No audio",
|
||||
"downloadAudio": "Download Audio",
|
||||
"playAudio": "Play",
|
||||
"pauseAudio": "Pause",
|
||||
"backend": "Backend Service",
|
||||
"localBackend": "Local Model",
|
||||
"backendSwitched": "Backend switched",
|
||||
"backendError": "Backend service error",
|
||||
"languageLabel": "Language",
|
||||
"speakerLabel": "Speaker",
|
||||
"speakerPlaceholder": "Select speaker",
|
||||
"textLabel": "Text to synthesize",
|
||||
"instructLabel": "Emotion guidance (optional)",
|
||||
"instructPlaceholderDesign": "Using preset guidance from voice design",
|
||||
"instructPlaceholderDefault": "e.g., gentle and caring, slow pace, full of warmth",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"advancedOptionsTitle": "Advanced Parameter Settings",
|
||||
"advancedOptionsDescription": "Adjust generation parameters to control audio quality and generation length",
|
||||
"creating": "Creating...",
|
||||
"taskCreated": "Task created",
|
||||
"taskCreateFailed": "Failed to create task",
|
||||
"loadDataFailed": "Failed to load data",
|
||||
"myVoiceDesigns": "My Voice Designs",
|
||||
"builtinSpeakers": "Built-in Speakers",
|
||||
"designDescriptionLabel": "Voice description",
|
||||
"designDescriptionPlaceholder": "e.g., Mature male, deep and magnetic, authoritative",
|
||||
"saveDesignButton": "Save Voice Design",
|
||||
"saveDesignTitle": "Save Voice Design",
|
||||
"saveDesignDescription": "Name and save the current voice design for quick future use",
|
||||
"designNameLabel": "Design name",
|
||||
"designNamePlaceholder": "e.g., Magnetic Male Voice",
|
||||
"preparing": "Preparing...",
|
||||
"designSaved": "Voice design saved",
|
||||
"clonePrepared": "Voice clone preparation completed",
|
||||
"clonePrepareFailed": "Clone preparation failed, but design saved",
|
||||
"saveFailed": "Save failed",
|
||||
"fillDesignDescription": "Please fill in voice description first",
|
||||
"fillDesignName": "Please enter design name",
|
||||
"advancedParams": {
|
||||
"maxNewTokens": {
|
||||
"label": "Max Generation Length",
|
||||
"description": "Controls the maximum length of generated audio. Higher values allow longer audio but increase processing time"
|
||||
},
|
||||
"temperature": {
|
||||
"label": "Temperature",
|
||||
"description": "Controls generation randomness. Higher values produce more varied output, lower values are more stable and consistent"
|
||||
},
|
||||
"topK": {
|
||||
"label": "Top K",
|
||||
"description": "Only considers the K most probable candidates during sampling. Smaller values are more deterministic, larger values are more diverse"
|
||||
},
|
||||
"topP": {
|
||||
"label": "Top P (Nucleus Sampling)",
|
||||
"description": "Cumulative probability threshold for sampling. Controls output diversity by sampling from candidates with cumulative probability up to P"
|
||||
},
|
||||
"repetitionPenalty": {
|
||||
"label": "Repetition Penalty",
|
||||
"description": "Penalizes repetitive content generation. Higher values avoid repetition more, but excessive values may affect naturalness"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"userManagement": "User Management",
|
||||
"userList": "User List",
|
||||
"userId": "User ID",
|
||||
"username": "Username",
|
||||
"role": "Role",
|
||||
"createdAt": "Created At",
|
||||
"lastLogin": "Last Login",
|
||||
"actions": "Actions",
|
||||
"addUser": "Add User",
|
||||
"editUser": "Edit User",
|
||||
"deleteUser": "Delete User",
|
||||
"deleteUserConfirm": "Are you sure you want to delete user {{username}}?",
|
||||
"userDeleted": "User deleted",
|
||||
"userAdded": "User added",
|
||||
"userUpdated": "User updated",
|
||||
"userOperationFailed": "User operation failed",
|
||||
"roleAdmin": "Admin",
|
||||
"roleUser": "User",
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed",
|
||||
"noUsers": "No users",
|
||||
"searchUsers": "Search Users",
|
||||
"filterByRole": "Filter by Role",
|
||||
"allRoles": "All Roles",
|
||||
"createUser": "Create User",
|
||||
"loadUsersFailed": "Failed to load user list",
|
||||
"userUpdateSuccess": "User updated successfully",
|
||||
"userCreateSuccess": "User created successfully",
|
||||
"operationFailed": "Operation failed",
|
||||
"userDeleteSuccess": "User deleted successfully",
|
||||
"deleteFailed": "Delete failed",
|
||||
"createUserDialog": "Create User",
|
||||
"editUserDialog": "Edit User",
|
||||
"createUserDescription": "Create new user and configure basic information",
|
||||
"editUserDescription": "Modify user information and permission settings",
|
||||
"email": "Email",
|
||||
"passwordOptional": "Password (leave blank to keep unchanged)",
|
||||
"isActive": "Active Status",
|
||||
"isSuperuser": "Super Administrator",
|
||||
"canUseLocalModel": "Local Model Permission",
|
||||
"canUseLocalModelDescription": "Allow user to use local TTS model",
|
||||
"canUseNsfw": "NSFW Script Permission",
|
||||
"canUseNsfwDescription": "Allow user to use NSFW content generation",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "Saving...",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"superuser": "Super Admin",
|
||||
"normalUser": "Normal User",
|
||||
"localModelPermission": "Local Model",
|
||||
"noPermission": "None",
|
||||
"validation": {
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
"usernameMaxLength": "Username cannot exceed 20 characters",
|
||||
"emailInvalid": "Please enter a valid email address"
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"voiceDesign": "Voice Design",
|
||||
"voiceClone": "Voice Clone",
|
||||
"designName": "Voice Name",
|
||||
"designNamePlaceholder": "Enter voice name",
|
||||
"designDescription": "Voice Description",
|
||||
"designDescriptionPlaceholder": "Describe voice characteristics...",
|
||||
"referenceAudio": "Reference Audio",
|
||||
"uploadReference": "Upload Reference Audio",
|
||||
"referenceText": "Reference Text",
|
||||
"referenceTextPlaceholder": "Enter text content of reference audio...",
|
||||
"cloneName": "Clone Name",
|
||||
"cloneNamePlaceholder": "Enter clone voice name",
|
||||
"cloneDescription": "Clone Description",
|
||||
"cloneDescriptionPlaceholder": "Describe clone voice...",
|
||||
"uploadAudio": "Upload Audio",
|
||||
"audioFile": "Audio File",
|
||||
"audioText": "Audio Text",
|
||||
"audioTextPlaceholder": "Enter text corresponding to audio...",
|
||||
"saveVoice": "Save Voice",
|
||||
"savingVoice": "Saving...",
|
||||
"voiceSaved": "Voice saved",
|
||||
"voiceSaveFailed": "Voice save failed",
|
||||
"deleteVoice": "Delete Voice",
|
||||
"deleteVoiceConfirm": "Are you sure you want to delete this voice?",
|
||||
"voiceDeleted": "Voice deleted",
|
||||
"voiceList": "Voice List",
|
||||
"noVoices": "No voices",
|
||||
"selectVoice": "Select Voice",
|
||||
"voiceDetails": "Voice Details",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At",
|
||||
"step1Title": "Audio Material",
|
||||
"step2Title": "Synthesis Settings",
|
||||
"uploadTab": "Upload Audio",
|
||||
"recordTab": "Record Online",
|
||||
"refAudioLabel": "Reference Audio File",
|
||||
"refTextLabel": "Reference transcript (optional, improves accuracy)",
|
||||
"refTextPlaceholder": "Text content corresponding to reference audio...",
|
||||
"nextStep": "Next",
|
||||
"prevStep": "Previous",
|
||||
"readPrompt": "Please read one of the following paragraphs:",
|
||||
"currentRefText": "Current reference text",
|
||||
"currentRefTextPlaceholder": "Selected text will appear here...",
|
||||
"languageOptional": "Language (optional)",
|
||||
"fastMode": "Fast mode",
|
||||
"useCache": "Use cache",
|
||||
"uploadAudioFile": "Upload Audio",
|
||||
"recordOnline": "Record Online",
|
||||
"validationFailed": "File validation failed",
|
||||
"validating": "Validating...",
|
||||
"selectAudioFile": "Select audio file",
|
||||
"seconds": "seconds",
|
||||
"recordingValidationFailed": "Recording validation failed",
|
||||
"browserNotSupported": "Your browser does not support recording",
|
||||
"recordingComplete": "Recording complete",
|
||||
"releaseToFinish": "Release to finish",
|
||||
"holdToRecord": "Hold to record",
|
||||
"myVoices": "My Voices",
|
||||
"loadFailed": "Failed to load voices",
|
||||
"deleteFailed": "Delete failed",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"local": "Local",
|
||||
"deleting": "Deleting..."
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
|
||||
import zhCN from './zh-CN'
|
||||
import zhTW from './zh-TW'
|
||||
import enUS from './en-US'
|
||||
import jaJP from './ja-JP'
|
||||
import koKR from './ko-KR'
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
'en-US': enUS,
|
||||
'ja-JP': jaJP,
|
||||
'ko-KR': koKR,
|
||||
},
|
||||
fallbackLng: 'zh-CN',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -1,154 +0,0 @@
|
||||
{
|
||||
"title": "オーディオブック生成",
|
||||
"llmConfig": "LLM 設定",
|
||||
"newProject": "新規プロジェクト",
|
||||
"loading": "読み込み中...",
|
||||
"noProjects": "オーディオブックプロジェクトがありません",
|
||||
"noProjectsHint": "「新規プロジェクト」をクリックして作成を開始",
|
||||
|
||||
"status": {
|
||||
"pending": "未分析",
|
||||
"analyzing": "分析中",
|
||||
"characters_ready": "キャラクター確認待ち",
|
||||
"parsing": "対話抽出中",
|
||||
"ready": "生成待ち",
|
||||
"processing": "処理中",
|
||||
"generating": "生成中",
|
||||
"done": "完了",
|
||||
"error": "エラー"
|
||||
},
|
||||
|
||||
"stepHints": {
|
||||
"pending": "ステップ 1:「分析」をクリック — LLM がキャラクターリストを自動抽出します",
|
||||
"analyzing": "ステップ 1:LLM がキャラクターを抽出中です。少々お待ちください...",
|
||||
"characters_ready": "ステップ 2:キャラクター情報を確認し、「キャラクター確認 · 章を識別」をクリック",
|
||||
"ready": "ステップ 3:章ごとに解析(LLM)— 解析済みの章はすぐに音声生成できます",
|
||||
"generating": "ステップ 4:音声合成中 — 完成したセグメントはすぐに再生できます"
|
||||
},
|
||||
|
||||
"llmConfigPanel": {
|
||||
"title": "LLM 設定",
|
||||
"current": "現在:{{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||
"hasKey": "APIキー設定済み",
|
||||
"noKey": "APIキー未設定",
|
||||
"notSet": "未設定",
|
||||
"saving": "保存中...",
|
||||
"save": "設定を保存",
|
||||
"savedSuccess": "LLM 設定を保存しました",
|
||||
"incompleteError": "LLM 設定をすべて入力してください"
|
||||
},
|
||||
|
||||
"createPanel": {
|
||||
"title": "新規オーディオブックプロジェクト",
|
||||
"titlePlaceholder": "タイトル",
|
||||
"pasteText": "テキストを貼り付け",
|
||||
"uploadEpub": "EPUB アップロード",
|
||||
"textPlaceholder": "小説のテキストを貼り付け...",
|
||||
"creating": "作成中...",
|
||||
"create": "プロジェクト作成",
|
||||
"createdSuccess": "プロジェクトを作成しました",
|
||||
"titleRequired": "タイトルを入力してください",
|
||||
"textRequired": "テキスト内容を入力してください",
|
||||
"epubRequired": "EPUB ファイルを選択してください"
|
||||
},
|
||||
|
||||
"projectCard": {
|
||||
"analyze": "分析",
|
||||
"reanalyze": "再分析",
|
||||
"reanalyzeConfirm": "再分析するとすべてのキャラクターと章のデータが削除されます。続けますか?",
|
||||
"analyzeStarted": "分析を開始しました",
|
||||
"generateAll": "全冊生成",
|
||||
"processAll": "⚡ 全冊一括処理",
|
||||
"downloadAll": "全冊ダウンロード",
|
||||
"deleteConfirm": "プロジェクト「{{title}}」とすべての音声を削除しますか?",
|
||||
"deleteSuccess": "プロジェクトを削除しました",
|
||||
"allDoneToast": "「{{title}}」の音声生成がすべて完了しました!",
|
||||
"segmentsProgress": "{{done}} / {{total}} セグメント完了",
|
||||
"chaptersProgress": "章解析:{{parsed}} / {{total}} 章",
|
||||
"chaptersParsing": "{{count}} 解析中",
|
||||
"chaptersError": "{{count}} エラー",
|
||||
"cancelParsing": "✖ 解析をキャンセル",
|
||||
"cancelGenerating": "✖ 生成をキャンセル",
|
||||
"retryFailed": "失敗を再試行",
|
||||
"cancelledToast": "キャンセルシグナルを送信しました。実行中のタスクは完了後停止します。",
|
||||
|
||||
"characters": {
|
||||
"title": "キャラクター({{count}} 人)",
|
||||
"namePlaceholder": "キャラクター名",
|
||||
"genderPlaceholder": "性別(未設定)",
|
||||
"genderMale": "男性",
|
||||
"genderFemale": "女性",
|
||||
"genderUnknown": "不明",
|
||||
"instructPlaceholder": "音声説明(TTS 用)",
|
||||
"descPlaceholder": "キャラクター説明",
|
||||
"voiceDesign": "音声 #{{id}}",
|
||||
"noVoice": "未割り当て",
|
||||
"editTitle": "キャラクターを編集:{{name}}",
|
||||
"savedSuccess": "キャラクターを保存しました",
|
||||
"regeneratingPreview": "試聴を再生成中...",
|
||||
"regeneratePreview": "試聴を再生成",
|
||||
"regenerateAll": "試聴を一括再生成",
|
||||
"regenerateAllDone": "すべての試聴を再生成しました",
|
||||
"previewNotReady": "試聴を収集中..."
|
||||
},
|
||||
|
||||
"confirm": {
|
||||
"button": "キャラクター確認 · 章を識別",
|
||||
"generateScript": "キャラクター確認 · 台本を生成",
|
||||
"loading": "識別中...",
|
||||
"chaptersRecognized": "章を識別しました"
|
||||
},
|
||||
|
||||
"chapters": {
|
||||
"title": "章一覧(全 {{count}} 章)",
|
||||
"processAll": "⚡ すべて処理",
|
||||
"parseAll": "一括抽出",
|
||||
"parseAllAI": "一括書き直し",
|
||||
"generateAll": "一括合成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "対話抽出",
|
||||
"parseAI": "章を書き直し",
|
||||
"parsing": "抽出中",
|
||||
"parseStarted": "「{{title}}」の抽出を開始しました",
|
||||
"parseStartedDefault": "章の抽出を開始しました",
|
||||
"reparse": "再抽出",
|
||||
"reparseAI": "書き直し",
|
||||
"generate": "音声合成",
|
||||
"generateStarted": "第 {{index}} 章の合成を開始しました",
|
||||
"generateAllStarted": "全冊合成を開始しました",
|
||||
"processAllStarted": "すべてのタスクを開始しました",
|
||||
"parseAllStarted": "一括抽出を開始しました",
|
||||
"doneBadge": "{{count}} セグメント完了",
|
||||
"segmentProgress": "{{done}}/{{total}} セグメント",
|
||||
"continueScript": "章を続けて生成",
|
||||
"continueScriptStarted": "続き生成を開始しました"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "AIスクリプトの続き生成",
|
||||
"label": "追加章数(1-20)",
|
||||
"start": "生成開始",
|
||||
"starting": "生成中..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "エラー",
|
||||
"unknownCharacter": "?",
|
||||
"edit": "編集",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"regenerate": "再生成",
|
||||
"regenerating": "生成中...",
|
||||
"savedSuccess": "セグメントを保存しました",
|
||||
"emotion": "感情",
|
||||
"noEmotion": "感情なし",
|
||||
"intensity": "強度"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
"play": "順番に再生({{count}} セグメント)",
|
||||
"stop": "停止",
|
||||
"progress": "{{current}} / {{total}}",
|
||||
"loading": "読み込み中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"login": "ログイン",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"loginButton": "ログイン",
|
||||
"loggingIn": "ログイン中...",
|
||||
"welcome": "Qwen TTSへようこそ",
|
||||
"loginPrompt": "続行するにはログインしてください",
|
||||
"loginSuccess": "ログインしました",
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"loginFailedCheckCredentials": "ログインに失敗しました。ユーザー名とパスワードを確認してください",
|
||||
"logoutSuccess": "ログアウトしました",
|
||||
"unauthorized": "未認証です。ログインしてください",
|
||||
"sessionExpired": "セッションが期限切れです。再度ログインしてください",
|
||||
"noPermission": "この操作を実行する権限がありません",
|
||||
"adminOnly": "この機能は管理者のみ利用できます",
|
||||
"usernamePlaceholder": "ユーザー名を入力",
|
||||
"passwordPlaceholder": "パスワードを入力",
|
||||
"validation": {
|
||||
"usernameMinLength": "ユーザー名は {{min}} 文字以上である必要があります",
|
||||
"usernameMaxLength": "ユーザー名は {{max}} 文字以下である必要があります",
|
||||
"passwordMinLength": "パスワードは {{min}} 文字以上である必要があります",
|
||||
"apiKeyRequired": "APIキーを入力してください"
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"add": "追加",
|
||||
"create": "作成",
|
||||
"update": "更新",
|
||||
"submit": "送信",
|
||||
"close": "閉じる",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"previous": "前へ",
|
||||
"search": "検索",
|
||||
"filter": "絞り込み",
|
||||
"clear": "クリア",
|
||||
"reset": "リセット",
|
||||
"loading": "読み込み中...",
|
||||
"noData": "データがありません",
|
||||
"success": "成功",
|
||||
"error": "エラー",
|
||||
"warning": "警告",
|
||||
"info": "情報",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"ok": "OK",
|
||||
"download": "ダウンロード",
|
||||
"upload": "アップロード",
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました",
|
||||
"view": "表示",
|
||||
"details": "詳細",
|
||||
"actions": "操作",
|
||||
"generatingAudio": "音声を生成中です、お待ちください...",
|
||||
"generationTakingLong": "生成に時間がかかっています、しばらくお待ちください...",
|
||||
"waitedSeconds": "{{seconds}}秒待機中",
|
||||
"loadingAudio": "読み込み中...",
|
||||
"failedToLoadAudio": "音声の読み込みに失敗しました"
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"languages": {
|
||||
"Auto": "自動検出",
|
||||
"Chinese": "中国語",
|
||||
"English": "英語",
|
||||
"Japanese": "日本語",
|
||||
"Korean": "韓国語",
|
||||
"German": "ドイツ語",
|
||||
"French": "フランス語",
|
||||
"Russian": "ロシア語",
|
||||
"Portuguese": "ポルトガル語",
|
||||
"Spanish": "スペイン語",
|
||||
"Italian": "イタリア語",
|
||||
"Cantonese": "広東語",
|
||||
"zh": "中国語",
|
||||
"en": "英語",
|
||||
"ja": "日本語",
|
||||
"ko": "韓国語",
|
||||
"yue": "広東語"
|
||||
},
|
||||
"speakers": {
|
||||
"Vivian": "女性、プロフェッショナルで明瞭",
|
||||
"Serena": "女性、優しく温かい",
|
||||
"Aria": "女性、活発で明るい",
|
||||
"Emma": "女性、成熟して落ち着いた",
|
||||
"Sophie": "女性、優雅で知的",
|
||||
"Isabella": "女性、穏やかで親しみやすい",
|
||||
"Ava": "女性、若々しくスタイリッシュ",
|
||||
"Oliver": "男性、磁性のある落ち着いた",
|
||||
"Lucas": "男性、明るく爽やか",
|
||||
"Ethan": "男性、プロフェッショナルで堂々とした",
|
||||
"Noah": "男性、温和で親しみやすい",
|
||||
"Liam": "男性、若々しく活力的"
|
||||
},
|
||||
"presetInstructs": [
|
||||
{
|
||||
"label": "Happy",
|
||||
"instruct": "very happy",
|
||||
"text": "The weather is so nice today, let's go to the park together!"
|
||||
},
|
||||
{
|
||||
"label": "Sad",
|
||||
"instruct": "very sad, with a crying tone",
|
||||
"text": "I'm sorry, I really tried my best, but I still let you down."
|
||||
},
|
||||
{
|
||||
"label": "Angry",
|
||||
"instruct": "very angry, with intense tone",
|
||||
"text": "How could you do this! This is absolutely unacceptable!"
|
||||
},
|
||||
{
|
||||
"label": "Gentle Care",
|
||||
"instruct": "gentle and caring, slow pace, soft tone, full of care and comfort",
|
||||
"text": "Don't worry, everything will be fine. I'll always be here with you."
|
||||
},
|
||||
{
|
||||
"label": "Excited",
|
||||
"instruct": "very excited, faster pace, rising tone, full of energy and enthusiasm",
|
||||
"text": "Awesome! We finally made it! This is so exciting!"
|
||||
},
|
||||
{
|
||||
"label": "Anxious",
|
||||
"instruct": "anxious tone, slightly faster pace, unstable tone, with tension and worry",
|
||||
"text": "What should we do? We're running out of time, we won't make it, what can we do?"
|
||||
},
|
||||
{
|
||||
"label": "Professional Broadcaster",
|
||||
"instruct": "Professional news broadcaster. Pace: standard broadcasting speed, clear articulation. Emotion: calm and professional, without personal emotion. Tone: mostly flat with slight variations, emphasis on key words. Character: rigorous, objective, authoritative.",
|
||||
"text": "According to Reuters, our space program has achieved a major breakthrough, with the successful completion of manned space missions."
|
||||
},
|
||||
{
|
||||
"label": "Warm Mentor",
|
||||
"instruct": "Warm mentor. Pace: unhurried, speaking slowly. Tone: stable with encouraging rises. Emotion: caring, patient, encouraging. Character: understanding, guiding, full of positive energy.",
|
||||
"text": "Everyone has their own pace, don't rush. Take your time, you will definitely find your own path."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Full of energy. Pace: fast, crisp articulation. Emotion: cheerful and optimistic, energetic. Tone: strong sense of rhythm, cadence. Character: outgoing, confident, enthusiastic, full of youthful spirit.",
|
||||
"text": "Wow, this game is so cool! Let's team up and play together, I promise I'll carry you!"
|
||||
}
|
||||
],
|
||||
"presetVoiceDesigns": [
|
||||
{
|
||||
"label": "Sweet Girl",
|
||||
"instruct": "Young female, sweet and bright voice, with a touch of girlish charm. High pitch, lively and varied intonation. Moderate pace, clear articulation. Cheerful and relaxed emotion, full of youthful energy. Suitable for: customer service, voice assistant, entertainment content.",
|
||||
"text": "Hello, I'm happy to help you! How may I assist you today?"
|
||||
},
|
||||
{
|
||||
"label": "Mature Woman",
|
||||
"instruct": "Mature and intellectual female voice, warm and full tone, with professional woman's capable temperament. Medium pitch, stable range. Moderate to fast pace, clear and organized. Calm and confident emotion, conveying professionalism and reliability.",
|
||||
"text": "According to the latest market analysis report, this quarter's performance shows steady growth, with all indicators meeting expected targets."
|
||||
},
|
||||
{
|
||||
"label": "Magnetic Male",
|
||||
"instruct": "Mid-low male voice, deep and magnetic tone, very appealing. Slow pace, steady rhythm. Moderate volume, thick and powerful voice. Suitable for emotional content, storytelling, brand promotion.",
|
||||
"text": "Night falls, yet the city lights remain brilliant. Under each light, there's a story about dreams."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Energetic young male, bright and clear tone, with youthful vigor. Fast pace, strong sense of rhythm. Enthusiastic and positive emotion, very appealing. Suitable for sports, gaming, entertainment.",
|
||||
"text": "Brothers, are you ready? Today we're going to challenge the new dungeon, let's go!"
|
||||
},
|
||||
{
|
||||
"label": "Authority Expert",
|
||||
"instruct": "Middle-aged male expert image, calm and authoritative tone, thick and powerful voice. Moderate pace, clear and standard articulation. Serious and professional emotion, conveying trust and expertise. Suitable for academic lectures, knowledge popularization, formal occasions.",
|
||||
"text": "From a historical development perspective, technological innovation has always been the core driving force for social progress."
|
||||
},
|
||||
{
|
||||
"label": "Gentle Mother",
|
||||
"instruct": "Gentle and loving middle-aged female, soft and warm tone, full of maternal care. Slow pace, calm and soothing tone. Warm and caring emotion, giving a sense of security. Suitable for children's content, emotional companionship, bedtime stories.",
|
||||
"text": "Sweetie, it's time to sleep. Mom will tell you a story. Once upon a time, there was a little rabbit who lived in the forest..."
|
||||
},
|
||||
{
|
||||
"label": "Broadcasting Host",
|
||||
"instruct": "Professional broadcasting host voice, full and round tone, standard pronunciation. Medium pitch, wide range. Standard pace, precise rhythm control. Professional and calm emotion, clear articulation. Suitable for news broadcasting, program hosting, formal reading.",
|
||||
"text": "Hello dear listeners, welcome to today's program. Next, we bring you today's news."
|
||||
},
|
||||
{
|
||||
"label": "Playful Girl",
|
||||
"instruct": "Playful and cute girl voice, light and lively sound, with unique girlish liveliness. High and varied tone, with coquettish and cute elements. Varying pace, clear articulation with cute interjections.",
|
||||
"text": "Oh no, I didn't mean to~ Can you forgive me this time? Please please~"
|
||||
}
|
||||
],
|
||||
"presetRefTexts": [
|
||||
{
|
||||
"label": "Natural Life",
|
||||
"text": "In this fast-paced world, we're always rushing forward, forgetting to pause and listen to our inner voice. Life is not just about the busyness before us, but also the poetry in the distance and the little moments of happiness we discover. May this recording bring you a touch of gentleness and strength, like an afternoon breeze. No matter how the future changes, remember to keep your love for life and embrace every bright tomorrow."
|
||||
},
|
||||
{
|
||||
"label": "Professional Formal",
|
||||
"text": "Technological progress allows us to transcend the boundaries of time and space, continuing emotions and memories through digitalization. Voice cloning is not only precise code logic, but also a bridge connecting humanity with future intelligence. Through the continuous evolution of deep learning and neural networks, every subtle intonation can be accurately captured. Let us witness together how technology gives voice a more vital expression."
|
||||
},
|
||||
{
|
||||
"label": "Literary Narrative",
|
||||
"text": "The spring breeze brushes the willow tips, carrying the fragrance of earth and news of blooming flowers. Have you ever anticipated meeting your long-lost self at some street corner? Whether it's hearty laughter or low whispers, each voice is a unique mark of life. Let us record this moment, let memories flow in the sound, becoming an eternal melody."
|
||||
}
|
||||
],
|
||||
"uiLanguages": {
|
||||
"zh-CN": "Simplified Chinese",
|
||||
"zh-TW": "Traditional Chinese",
|
||||
"en-US": "English",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean"
|
||||
},
|
||||
"uiLanguagesShort": {
|
||||
"zh-CN": "ZH-CN",
|
||||
"zh-TW": "ZH-TW",
|
||||
"en-US": "EN",
|
||||
"ja-JP": "JA",
|
||||
"ko-KR": "KO"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"http": {
|
||||
"400": "リクエストパラメータエラー",
|
||||
"401": "認証されていません。ログインしてください",
|
||||
"403": "アクセスが禁止されています",
|
||||
"404": "リクエストされたリソースが存在しません",
|
||||
"500": "サーバー内部エラー",
|
||||
"502": "ゲートウェイエラー",
|
||||
"503": "サービスが一時的に利用できません",
|
||||
"default": "リクエストに失敗しました。後でもう一度お試しください"
|
||||
},
|
||||
"fieldNames": {
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"text": "テキスト",
|
||||
"language": "言語",
|
||||
"speaker": "話者",
|
||||
"instruct": "感情指示",
|
||||
"name": "名前",
|
||||
"description": "説明",
|
||||
"audio_file": "オーディオファイル",
|
||||
"reference_audio": "参照オーディオ",
|
||||
"reference_text": "参照テキスト",
|
||||
"api_key": "APIキー",
|
||||
"backend": "バックエンドサービス"
|
||||
},
|
||||
"validation": {
|
||||
"required": "{{field}}を入力してください",
|
||||
"minLength": "{{field}}は少なくとも{{min}}文字必要です",
|
||||
"maxLength": "{{field}}は{{max}}文字を超えることはできません",
|
||||
"invalid": "{{field}}の形式が正しくありません",
|
||||
"notFound": "{{field}}が見つかりません",
|
||||
"alreadyExists": "{{field}}は既に存在します",
|
||||
"uploadFailed": "{{field}}のアップロードに失敗しました",
|
||||
"fileTooLarge": "ファイルサイズは{{size}}MBを超えることはできません",
|
||||
"invalidFileType": "サポートされていないファイルタイプです"
|
||||
},
|
||||
"networkError": "ネットワーク接続に失敗しました。ネットワークを確認してください",
|
||||
"unknownError": "不明なエラー",
|
||||
"operationFailed": "操作に失敗しました",
|
||||
"tryAgain": "もう一度お試しください"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import common from './common.json'
|
||||
import nav from './nav.json'
|
||||
import auth from './auth.json'
|
||||
import tts from './tts.json'
|
||||
import voice from './voice.json'
|
||||
import job from './job.json'
|
||||
import settings from './settings.json'
|
||||
import user from './user.json'
|
||||
import errors from './errors.json'
|
||||
import constants from './constants.json'
|
||||
import onboarding from './onboarding.json'
|
||||
import audiobook from './audiobook.json'
|
||||
|
||||
export default {
|
||||
common,
|
||||
nav,
|
||||
auth,
|
||||
tts,
|
||||
voice,
|
||||
job,
|
||||
settings,
|
||||
user,
|
||||
errors,
|
||||
constants,
|
||||
onboarding,
|
||||
audiobook,
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"jobHistory": "ジョブ履歴",
|
||||
"jobList": "ジョブリスト",
|
||||
"jobDetails": "ジョブ詳細",
|
||||
"jobId": "ジョブID",
|
||||
"status": "ステータス",
|
||||
"createdAt": "作成日時",
|
||||
"completedAt": "完了日時",
|
||||
"duration": "処理時間",
|
||||
"statusPending": "待機中",
|
||||
"statusProcessing": "処理中",
|
||||
"statusCompleted": "完了",
|
||||
"statusFailed": "失敗",
|
||||
"noJobs": "ジョブがありません",
|
||||
"viewJob": "ジョブを表示",
|
||||
"deleteJob": "ジョブを削除",
|
||||
"deleteJobConfirm": "このジョブを削除してもよろしいですか?",
|
||||
"jobDeleted": "ジョブを削除しました",
|
||||
"refreshJobs": "ジョブを更新",
|
||||
"inputText": "入力テキスト",
|
||||
"parameters": "パラメータ",
|
||||
"result": "結果",
|
||||
"errorMessage": "エラーメッセージ",
|
||||
"downloadResult": "結果をダウンロード",
|
||||
"retryJob": "再試行",
|
||||
"cancelJob": "ジョブをキャンセル",
|
||||
"historyTitle": "履歴",
|
||||
"historyCount": "{{count}}件の記録",
|
||||
"retry": "再試行",
|
||||
"noHistory": "履歴がありません",
|
||||
"historyDescription": "音声生成後、記録がここに表示されます",
|
||||
"detailsDescription": "ジョブの詳細パラメータと生成結果を表示",
|
||||
"basicInfo": "基本情報",
|
||||
"speaker": "話者: ",
|
||||
"language": "言語: ",
|
||||
"autoDetect": "自動検出",
|
||||
"fastMode": "高速モード: ",
|
||||
"useCache": "キャッシュ使用: ",
|
||||
"synthesisText": "合成テキスト",
|
||||
"notSet": "未設定",
|
||||
"voiceDescription": "音声説明",
|
||||
"emotionGuidance": "感情ガイダンス",
|
||||
"referenceText": "参照テキスト",
|
||||
"notProvided": "提供なし",
|
||||
"advancedParameters": "詳細パラメータ",
|
||||
"maxNewTokens": "最大生成長: ",
|
||||
"temperature": "Temperature: ",
|
||||
"topK": "Top K: ",
|
||||
"topP": "Top P: ",
|
||||
"repetitionPenalty": "繰り返しペナルティ: ",
|
||||
"audioPlayback": "音声再生",
|
||||
"typeCustomVoice": "カスタム音声",
|
||||
"typeVoiceDesign": "音声デザイン",
|
||||
"typeVoiceClone": "音声クローン"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"home": "ホーム",
|
||||
"settings": "設定",
|
||||
"userManagement": "ユーザー管理",
|
||||
"logout": "ログアウト",
|
||||
"login": "ログイン",
|
||||
"toggleTheme": "テーマ切替",
|
||||
"changeLanguage": "言語変更",
|
||||
"customVoiceTab": "カスタム",
|
||||
"voiceDesignTab": "音色作成",
|
||||
"voiceCloneTab": "クローン",
|
||||
"voiceManagement": "音声管理"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"welcome": "Qwen3 TTSへようこそ",
|
||||
"localModel": "ローカルモデル",
|
||||
"localModelDescription": "無料でローカルQwen3-TTSモデルを使用",
|
||||
"localModelNoPermission": "ローカルモデルの権限がありません。管理者にお問い合わせください",
|
||||
"skipConfig": "設定をスキップ",
|
||||
"back": "戻る",
|
||||
"skipSuccess": "設定をスキップしました。デフォルトでローカルモードを使用します",
|
||||
"operationFailed": "操作に失敗しました。再試行してください",
|
||||
"configComplete": "設定が完了しました。デフォルトでローカルモードを使用します",
|
||||
"saveFailed": "設定の保存に失敗しました。再試行してください"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"title": "設定",
|
||||
"description": "アカウント設定と環境設定を管理",
|
||||
"language": "表示言語",
|
||||
"languageDescription": "インターフェース言語を選択",
|
||||
"theme": "テーマ",
|
||||
"themeLight": "ライト",
|
||||
"themeDark": "ダーク",
|
||||
"themeSystem": "システム設定",
|
||||
"accountInfo": "アカウント情報",
|
||||
"accountInfoDescription": "アカウントの基本情報",
|
||||
"email": "メールアドレス",
|
||||
"changePassword": "パスワード変更",
|
||||
"passwordChangeSuccess": "パスワードを変更しました",
|
||||
"passwordChangeFailed": "パスワードの変更に失敗しました",
|
||||
"grokConfig": "Grok-4 設定(NSFWモード)",
|
||||
"grokConfigDescription": "NSFWスクリプト生成用のGrok APIを設定します。NSFW権限を持つユーザーに適用されます",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "モデル(デフォルト: grok-4)"
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"customVoice": "カスタム音声",
|
||||
"voiceDesign": "音声デザイン",
|
||||
"voiceClone": "音声クローン",
|
||||
"text": "テキスト",
|
||||
"textPlaceholder": "合成するテキストを入力してください...",
|
||||
"language": "言語",
|
||||
"speaker": "話者",
|
||||
"instruct": "感情指示",
|
||||
"instructPlaceholder": "例:とても嬉しい、少し悲しい...",
|
||||
"customSpeaker": "カスタム話者",
|
||||
"customSpeakerPlaceholder": "例:Vivian、Alice...",
|
||||
"presetInstructs": "プリセット感情",
|
||||
"generate": "音声生成",
|
||||
"generating": "生成中...",
|
||||
"generationSuccess": "音声の生成に成功しました",
|
||||
"generationFailed": "音声の生成に失敗しました",
|
||||
"audioPlayer": "オーディオプレーヤー",
|
||||
"noAudio": "オーディオがありません",
|
||||
"downloadAudio": "オーディオをダウンロード",
|
||||
"playAudio": "再生",
|
||||
"pauseAudio": "一時停止",
|
||||
"backend": "バックエンドサービス",
|
||||
"localBackend": "ローカルモデル",
|
||||
"backendSwitched": "バックエンドを切り替えました",
|
||||
"backendError": "バックエンドサービスエラー",
|
||||
"languageLabel": "言語",
|
||||
"speakerLabel": "話者",
|
||||
"speakerPlaceholder": "話者を選択",
|
||||
"textLabel": "合成テキスト",
|
||||
"instructLabel": "感情ガイダンス(オプション)",
|
||||
"instructPlaceholderDesign": "音声デザインのプリセットガイダンスを使用",
|
||||
"instructPlaceholderDefault": "例:優しく思いやりのある、ゆっくりとしたペース、温かみのある",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"advancedOptionsTitle": "詳細パラメータ設定",
|
||||
"advancedOptionsDescription": "生成パラメータを調整して、オーディオ品質と生成長を制御します",
|
||||
"creating": "作成中...",
|
||||
"taskCreated": "タスクを作成しました",
|
||||
"taskCreateFailed": "タスクの作成に失敗しました",
|
||||
"loadDataFailed": "データの読み込みに失敗しました",
|
||||
"myVoiceDesigns": "マイ音声デザイン",
|
||||
"builtinSpeakers": "組み込み話者",
|
||||
"designDescriptionLabel": "音声説明",
|
||||
"designDescriptionPlaceholder": "例:成熟した男性、低くて磁性的、権威的",
|
||||
"saveDesignButton": "音声デザインを保存",
|
||||
"saveDesignTitle": "音声デザインを保存",
|
||||
"saveDesignDescription": "現在の音声デザインに名前を付けて保存し、今後すばやく使用できるようにします",
|
||||
"designNameLabel": "デザイン名",
|
||||
"designNamePlaceholder": "例:磁性的な男性の声",
|
||||
"preparing": "準備中...",
|
||||
"designSaved": "音声デザインを保存しました",
|
||||
"clonePrepared": "音声クローンの準備が完了しました",
|
||||
"clonePrepareFailed": "クローンの準備に失敗しましたが、デザインは保存されました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"fillDesignDescription": "音声説明を入力してください",
|
||||
"fillDesignName": "デザイン名を入力してください",
|
||||
"advancedParams": {
|
||||
"maxNewTokens": {
|
||||
"label": "最大生成長",
|
||||
"description": "生成されるオーディオの最大長を制御します。値が大きいほど長いオーディオを生成できますが、処理時間も増加します"
|
||||
},
|
||||
"temperature": {
|
||||
"label": "温度",
|
||||
"description": "生成のランダム性を制御します。値が高いほど多様な出力、低いほど安定した一貫性のある出力になります"
|
||||
},
|
||||
"topK": {
|
||||
"label": "Top K",
|
||||
"description": "サンプリング時に確率が最も高いK個の候補のみを考慮します。値が小さいほど確定的、大きいほど多様になります"
|
||||
},
|
||||
"topP": {
|
||||
"label": "Top P (ニュークリアスサンプリング)",
|
||||
"description": "累積確率の閾値。累積確率がPに達する候補からサンプリングします。出力の多様性を制御します"
|
||||
},
|
||||
"repetitionPenalty": {
|
||||
"label": "繰り返しペナルティ",
|
||||
"description": "繰り返しコンテンツの生成を抑制します。値が大きいほど繰り返しを避けますが、過度に大きいと自然さに影響する可能性があります"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"userManagement": "ユーザー管理",
|
||||
"userList": "ユーザーリスト",
|
||||
"userId": "ユーザーID",
|
||||
"username": "ユーザー名",
|
||||
"role": "ロール",
|
||||
"createdAt": "作成日時",
|
||||
"lastLogin": "最終ログイン",
|
||||
"actions": "操作",
|
||||
"addUser": "ユーザーを追加",
|
||||
"editUser": "ユーザーを編集",
|
||||
"deleteUser": "ユーザーを削除",
|
||||
"deleteUserConfirm": "ユーザー {{username}} を削除してもよろしいですか?",
|
||||
"userDeleted": "ユーザーを削除しました",
|
||||
"userAdded": "ユーザーを追加しました",
|
||||
"userUpdated": "ユーザーを更新しました",
|
||||
"userOperationFailed": "ユーザー操作に失敗しました",
|
||||
"roleAdmin": "管理者",
|
||||
"roleUser": "一般ユーザー",
|
||||
"password": "パスワード",
|
||||
"newPassword": "新しいパスワード",
|
||||
"confirmPassword": "パスワード確認",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"changePassword": "パスワード変更",
|
||||
"passwordChanged": "パスワードを変更しました",
|
||||
"noUsers": "ユーザーがいません",
|
||||
"searchUsers": "ユーザーを検索",
|
||||
"filterByRole": "ロールで絞り込み",
|
||||
"allRoles": "すべてのロール",
|
||||
"createUser": "ユーザーを作成",
|
||||
"loadUsersFailed": "ユーザーリストの読み込みに失敗しました",
|
||||
"userUpdateSuccess": "ユーザーを更新しました",
|
||||
"userCreateSuccess": "ユーザーを作成しました",
|
||||
"operationFailed": "操作に失敗しました",
|
||||
"userDeleteSuccess": "ユーザーを削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"createUserDialog": "ユーザーを作成",
|
||||
"editUserDialog": "ユーザーを編集",
|
||||
"createUserDescription": "新しいユーザーを作成し、基本情報を設定",
|
||||
"editUserDescription": "ユーザー情報と権限設定を変更",
|
||||
"email": "メールアドレス",
|
||||
"passwordOptional": "パスワード (空欄の場合は変更なし)",
|
||||
"isActive": "アクティブ状態",
|
||||
"isSuperuser": "スーパー管理者",
|
||||
"canUseLocalModel": "ローカルモデル権限",
|
||||
"canUseLocalModelDescription": "ユーザーにローカルTTSモデルの使用を許可",
|
||||
"canUseNsfw": "NSFWスクリプト権限",
|
||||
"canUseNsfwDescription": "ユーザーにNSFWコンテンツ生成を許可",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "保存中...",
|
||||
"active": "アクティブ",
|
||||
"inactive": "非アクティブ",
|
||||
"superuser": "スーパー管理者",
|
||||
"normalUser": "一般ユーザー",
|
||||
"localModelPermission": "ローカルモデル",
|
||||
"noPermission": "なし",
|
||||
"validation": {
|
||||
"usernameMinLength": "ユーザー名は3文字以上である必要があります",
|
||||
"usernameMaxLength": "ユーザー名は20文字以下である必要があります",
|
||||
"emailInvalid": "有効なメールアドレスを入力してください"
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"voiceDesign": "音声デザイン",
|
||||
"voiceClone": "音声クローン",
|
||||
"designName": "音声名",
|
||||
"designNamePlaceholder": "音声名を入力してください",
|
||||
"designDescription": "音声説明",
|
||||
"designDescriptionPlaceholder": "音声の特徴を説明してください...",
|
||||
"referenceAudio": "参照オーディオ",
|
||||
"uploadReference": "参照オーディオをアップロード",
|
||||
"referenceText": "参照テキスト",
|
||||
"referenceTextPlaceholder": "参照オーディオのテキスト内容を入力してください...",
|
||||
"cloneName": "クローン名",
|
||||
"cloneNamePlaceholder": "クローン音声名を入力してください",
|
||||
"cloneDescription": "クローン説明",
|
||||
"cloneDescriptionPlaceholder": "クローン音声を説明してください...",
|
||||
"uploadAudio": "オーディオをアップロード",
|
||||
"audioFile": "オーディオファイル",
|
||||
"audioText": "オーディオテキスト",
|
||||
"audioTextPlaceholder": "オーディオに対応するテキストを入力してください...",
|
||||
"saveVoice": "音声を保存",
|
||||
"savingVoice": "保存中...",
|
||||
"voiceSaved": "音声を保存しました",
|
||||
"voiceSaveFailed": "音声の保存に失敗しました",
|
||||
"deleteVoice": "音声を削除",
|
||||
"deleteVoiceConfirm": "この音声を削除してもよろしいですか?",
|
||||
"voiceDeleted": "音声を削除しました",
|
||||
"voiceList": "音声リスト",
|
||||
"noVoices": "音声がありません",
|
||||
"selectVoice": "音声を選択",
|
||||
"voiceDetails": "音声詳細",
|
||||
"createdAt": "作成日時",
|
||||
"updatedAt": "更新日時",
|
||||
"step1Title": "オーディオ素材",
|
||||
"step2Title": "合成設定",
|
||||
"uploadTab": "オーディオをアップロード",
|
||||
"recordTab": "オンライン録音",
|
||||
"refAudioLabel": "参照オーディオファイル",
|
||||
"refTextLabel": "参照トランスクリプト(オプション、精度向上)",
|
||||
"refTextPlaceholder": "参照オーディオに対応するテキスト内容...",
|
||||
"nextStep": "次へ",
|
||||
"prevStep": "前へ",
|
||||
"readPrompt": "次のいずれかの段落を読んでください:",
|
||||
"currentRefText": "現在の参照テキスト",
|
||||
"currentRefTextPlaceholder": "選択したテキストがここに表示されます...",
|
||||
"languageOptional": "言語(オプション)",
|
||||
"fastMode": "高速モード",
|
||||
"useCache": "キャッシュを使用",
|
||||
"uploadAudioFile": "オーディオをアップロード",
|
||||
"recordOnline": "オンライン録音",
|
||||
"validationFailed": "ファイル検証に失敗しました",
|
||||
"validating": "検証中...",
|
||||
"selectAudioFile": "オーディオファイルを選択",
|
||||
"seconds": "秒",
|
||||
"recordingValidationFailed": "録音検証に失敗しました",
|
||||
"browserNotSupported": "お使いのブラウザは録音機能をサポートしていません",
|
||||
"recordingComplete": "録音完了",
|
||||
"releaseToFinish": "離して完了",
|
||||
"holdToRecord": "長押しで録音",
|
||||
"myVoices": "マイ音声",
|
||||
"loadFailed": "音声の読み込みに失敗しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"deleteConfirmDesc": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"local": "ローカル",
|
||||
"deleting": "削除中..."
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
{
|
||||
"title": "오디오북 생성",
|
||||
"llmConfig": "LLM 설정",
|
||||
"newProject": "새 프로젝트",
|
||||
"loading": "로딩 중...",
|
||||
"noProjects": "오디오북 프로젝트가 없습니다",
|
||||
"noProjectsHint": "「새 프로젝트」를 클릭하여 시작하세요",
|
||||
|
||||
"status": {
|
||||
"pending": "분석 대기",
|
||||
"analyzing": "분석 중",
|
||||
"characters_ready": "캐릭터 확인 대기",
|
||||
"parsing": "대화 추출 중",
|
||||
"ready": "생성 대기",
|
||||
"processing": "처리 중",
|
||||
"generating": "생성 중",
|
||||
"done": "완료",
|
||||
"error": "오류"
|
||||
},
|
||||
|
||||
"stepHints": {
|
||||
"pending": "1단계: 「분석」을 클릭하면 LLM이 캐릭터 목록을 자동으로 추출합니다",
|
||||
"analyzing": "1단계: LLM이 캐릭터를 추출 중입니다. 잠시 기다려 주세요...",
|
||||
"characters_ready": "2단계: 캐릭터 정보를 확인한 후 「캐릭터 확인 · 챕터 식별」을 클릭하세요",
|
||||
"ready": "3단계: 챕터별로 대본을 파싱합니다(LLM). 파싱된 챕터는 즉시 음성 생성이 가능합니다",
|
||||
"generating": "4단계: 음성 합성 중 — 완료된 세그먼트는 즉시 재생할 수 있습니다"
|
||||
},
|
||||
|
||||
"llmConfigPanel": {
|
||||
"title": "LLM 설정",
|
||||
"current": "현재: {{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||
"hasKey": "API 키 설정됨",
|
||||
"noKey": "API 키 없음",
|
||||
"notSet": "미설정",
|
||||
"saving": "저장 중...",
|
||||
"save": "설정 저장",
|
||||
"savedSuccess": "LLM 설정이 저장되었습니다",
|
||||
"incompleteError": "LLM 설정을 모두 입력해 주세요"
|
||||
},
|
||||
|
||||
"createPanel": {
|
||||
"title": "새 오디오북 프로젝트",
|
||||
"titlePlaceholder": "제목",
|
||||
"pasteText": "텍스트 붙여넣기",
|
||||
"uploadEpub": "EPUB 업로드",
|
||||
"textPlaceholder": "소설 텍스트를 붙여넣으세요...",
|
||||
"creating": "생성 중...",
|
||||
"create": "프로젝트 생성",
|
||||
"createdSuccess": "프로젝트가 생성되었습니다",
|
||||
"titleRequired": "제목을 입력해 주세요",
|
||||
"textRequired": "텍스트 내용을 입력해 주세요",
|
||||
"epubRequired": "EPUB 파일을 선택해 주세요"
|
||||
},
|
||||
|
||||
"projectCard": {
|
||||
"analyze": "분석",
|
||||
"reanalyze": "재분석",
|
||||
"reanalyzeConfirm": "재분석하면 모든 캐릭터와 챕터 데이터가 삭제됩니다. 계속하시겠습니까?",
|
||||
"analyzeStarted": "분석이 시작되었습니다",
|
||||
"generateAll": "전체 책 생성",
|
||||
"processAll": "⚡ 전체 일괄 처리",
|
||||
"downloadAll": "전체 책 다운로드",
|
||||
"deleteConfirm": "프로젝트 「{{title}}」와 모든 음성을 삭제하시겠습니까?",
|
||||
"deleteSuccess": "프로젝트가 삭제되었습니다",
|
||||
"allDoneToast": "「{{title}}」 음성 생성이 모두 완료되었습니다!",
|
||||
"segmentsProgress": "{{done}} / {{total}} 세그먼트 완료",
|
||||
"chaptersProgress": "챕터 파싱: {{parsed}} / {{total}} 챕터",
|
||||
"chaptersParsing": "{{count}} 파싱 중",
|
||||
"chaptersError": "{{count}} 오류",
|
||||
"cancelParsing": "✖ 파싱 취소",
|
||||
"cancelGenerating": "✖ 생성 취소",
|
||||
"retryFailed": "실패 재시도",
|
||||
"cancelledToast": "취소 신호가 전송되었습니다. 실행 중인 작업은 완료 후 중지됩니다.",
|
||||
|
||||
"characters": {
|
||||
"title": "캐릭터 목록 ({{count}}명)",
|
||||
"namePlaceholder": "캐릭터 이름",
|
||||
"genderPlaceholder": "성별 (미설정)",
|
||||
"genderMale": "남성",
|
||||
"genderFemale": "여성",
|
||||
"genderUnknown": "알 수 없음",
|
||||
"instructPlaceholder": "음성 설명 (TTS용)",
|
||||
"descPlaceholder": "캐릭터 설명",
|
||||
"voiceDesign": "음성 #{{id}}",
|
||||
"noVoice": "미할당",
|
||||
"editTitle": "캐릭터 편집: {{name}}",
|
||||
"savedSuccess": "캐릭터가 저장되었습니다",
|
||||
"regeneratingPreview": "미리듣기 재생성 중...",
|
||||
"regeneratePreview": "미리듣기 재생성",
|
||||
"regenerateAll": "미리듣기 일괄 재생성",
|
||||
"regenerateAllDone": "모든 미리듣기가 재생성되었습니다",
|
||||
"previewNotReady": "미리듣기 수집 중..."
|
||||
},
|
||||
|
||||
"confirm": {
|
||||
"button": "캐릭터 확인 · 챕터 식별",
|
||||
"generateScript": "캐릭터 확인 · 대본 생성",
|
||||
"loading": "식별 중...",
|
||||
"chaptersRecognized": "챕터가 식별되었습니다"
|
||||
},
|
||||
|
||||
"chapters": {
|
||||
"title": "챕터 목록 (총 {{count}}챕터)",
|
||||
"processAll": "⚡ 전체 처리",
|
||||
"parseAll": "일괄 추출",
|
||||
"parseAllAI": "일괄 재작성",
|
||||
"generateAll": "일괄 합성",
|
||||
"defaultTitle": "제 {{index}} 장",
|
||||
"parse": "대화 추출",
|
||||
"parseAI": "챕터 재작성",
|
||||
"parsing": "추출 중",
|
||||
"parseStarted": "「{{title}}」 추출이 시작되었습니다",
|
||||
"parseStartedDefault": "챕터 추출이 시작되었습니다",
|
||||
"reparse": "재추출",
|
||||
"reparseAI": "재작성",
|
||||
"generate": "음성 합성",
|
||||
"generateStarted": "제 {{index}} 장 합성이 시작되었습니다",
|
||||
"generateAllStarted": "전체 책 합성이 시작되었습니다",
|
||||
"processAllStarted": "모든 작업이 시작되었습니다",
|
||||
"parseAllStarted": "일괄 추출이 시작되었습니다",
|
||||
"doneBadge": "{{count}}개 세그먼트 완료",
|
||||
"segmentProgress": "{{done}}/{{total}} 세그먼트",
|
||||
"continueScript": "챕터 계속 생성",
|
||||
"continueScriptStarted": "이어쓰기 생성이 시작되었습니다"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "AI 스크립트 이어쓰기",
|
||||
"label": "추가 챕터 수(1-20)",
|
||||
"start": "생성 시작",
|
||||
"starting": "생성 중..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "오류",
|
||||
"unknownCharacter": "?",
|
||||
"edit": "편집",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"regenerate": "재생성",
|
||||
"regenerating": "생성 중...",
|
||||
"savedSuccess": "세그먼트가 저장되었습니다",
|
||||
"emotion": "감정",
|
||||
"noEmotion": "감정 없음",
|
||||
"intensity": "강도"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
"play": "순차 재생 ({{count}}개 세그먼트)",
|
||||
"stop": "정지",
|
||||
"progress": "{{current}} / {{total}}",
|
||||
"loading": "로딩 중..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"login": "로그인",
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"loginButton": "로그인",
|
||||
"loggingIn": "로그인 중...",
|
||||
"welcome": "Qwen TTS에 오신 것을 환영합니다",
|
||||
"loginPrompt": "계속하려면 로그인하세요",
|
||||
"loginSuccess": "로그인했습니다",
|
||||
"loginFailed": "로그인에 실패했습니다",
|
||||
"loginFailedCheckCredentials": "로그인에 실패했습니다. 사용자 이름과 비밀번호를 확인하세요",
|
||||
"logoutSuccess": "로그아웃했습니다",
|
||||
"unauthorized": "인증되지 않았습니다. 로그인하세요",
|
||||
"sessionExpired": "세션이 만료되었습니다. 다시 로그인하세요",
|
||||
"noPermission": "이 작업을 수행할 권한이 없습니다",
|
||||
"adminOnly": "이 기능은 관리자만 사용할 수 있습니다",
|
||||
"usernamePlaceholder": "사용자 이름 입력",
|
||||
"passwordPlaceholder": "비밀번호 입력",
|
||||
"validation": {
|
||||
"usernameMinLength": "사용자 이름은 {{min}}자 이상이어야 합니다",
|
||||
"usernameMaxLength": "사용자 이름은 {{max}}자 이하여야 합니다",
|
||||
"passwordMinLength": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
"apiKeyRequired": "API 키를 입력하세요"
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"add": "추가",
|
||||
"create": "생성",
|
||||
"update": "업데이트",
|
||||
"submit": "제출",
|
||||
"close": "닫기",
|
||||
"back": "뒤로",
|
||||
"next": "다음",
|
||||
"previous": "이전",
|
||||
"search": "검색",
|
||||
"filter": "필터",
|
||||
"clear": "지우기",
|
||||
"reset": "재설정",
|
||||
"loading": "로딩 중...",
|
||||
"noData": "데이터 없음",
|
||||
"success": "성공",
|
||||
"error": "오류",
|
||||
"warning": "경고",
|
||||
"info": "정보",
|
||||
"yes": "예",
|
||||
"no": "아니오",
|
||||
"ok": "확인",
|
||||
"download": "다운로드",
|
||||
"upload": "업로드",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"view": "보기",
|
||||
"details": "상세정보",
|
||||
"actions": "작업",
|
||||
"generatingAudio": "오디오 생성 중, 잠시 기다려주세요...",
|
||||
"generationTakingLong": "생성에 시간이 오래 걸리고 있습니다, 조금만 기다려주세요...",
|
||||
"waitedSeconds": "{{seconds}}초 대기 중",
|
||||
"loadingAudio": "로딩 중...",
|
||||
"failedToLoadAudio": "오디오 로드에 실패했습니다"
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"languages": {
|
||||
"Auto": "자동 감지",
|
||||
"Chinese": "중국어",
|
||||
"English": "영어",
|
||||
"Japanese": "일본어",
|
||||
"Korean": "한국어",
|
||||
"German": "독일어",
|
||||
"French": "프랑스어",
|
||||
"Russian": "러시아어",
|
||||
"Portuguese": "포르투갈어",
|
||||
"Spanish": "스페인어",
|
||||
"Italian": "이탈리아어",
|
||||
"Cantonese": "광둥어",
|
||||
"zh": "중국어",
|
||||
"en": "영어",
|
||||
"ja": "일본어",
|
||||
"ko": "한국어",
|
||||
"yue": "광둥어"
|
||||
},
|
||||
"speakers": {
|
||||
"Vivian": "여성, 전문적이고 명확함",
|
||||
"Serena": "여성, 부드럽고 따뜻함",
|
||||
"Aria": "여성, 활발하고 명랑함",
|
||||
"Emma": "여성, 성숙하고 안정적임",
|
||||
"Sophie": "여성, 우아하고 지적임",
|
||||
"Isabella": "여성, 온화하고 친근함",
|
||||
"Ava": "여성, 젊고 세련됨",
|
||||
"Oliver": "남성, 자성적이고 침착함",
|
||||
"Lucas": "남성, 밝고 쾌활함",
|
||||
"Ethan": "남성, 전문적이고 당당함",
|
||||
"Noah": "남성, 온화하고 친절함",
|
||||
"Liam": "남성, 젊고 활력적임"
|
||||
},
|
||||
"presetInstructs": [
|
||||
{
|
||||
"label": "Happy",
|
||||
"instruct": "very happy",
|
||||
"text": "The weather is so nice today, let's go to the park together!"
|
||||
},
|
||||
{
|
||||
"label": "Sad",
|
||||
"instruct": "very sad, with a crying tone",
|
||||
"text": "I'm sorry, I really tried my best, but I still let you down."
|
||||
},
|
||||
{
|
||||
"label": "Angry",
|
||||
"instruct": "very angry, with intense tone",
|
||||
"text": "How could you do this! This is absolutely unacceptable!"
|
||||
},
|
||||
{
|
||||
"label": "Gentle Care",
|
||||
"instruct": "gentle and caring, slow pace, soft tone, full of care and comfort",
|
||||
"text": "Don't worry, everything will be fine. I'll always be here with you."
|
||||
},
|
||||
{
|
||||
"label": "Excited",
|
||||
"instruct": "very excited, faster pace, rising tone, full of energy and enthusiasm",
|
||||
"text": "Awesome! We finally made it! This is so exciting!"
|
||||
},
|
||||
{
|
||||
"label": "Anxious",
|
||||
"instruct": "anxious tone, slightly faster pace, unstable tone, with tension and worry",
|
||||
"text": "What should we do? We're running out of time, we won't make it, what can we do?"
|
||||
},
|
||||
{
|
||||
"label": "Professional Broadcaster",
|
||||
"instruct": "Professional news broadcaster. Pace: standard broadcasting speed, clear articulation. Emotion: calm and professional, without personal emotion. Tone: mostly flat with slight variations, emphasis on key words. Character: rigorous, objective, authoritative.",
|
||||
"text": "According to Reuters, our space program has achieved a major breakthrough, with the successful completion of manned space missions."
|
||||
},
|
||||
{
|
||||
"label": "Warm Mentor",
|
||||
"instruct": "Warm mentor. Pace: unhurried, speaking slowly. Tone: stable with encouraging rises. Emotion: caring, patient, encouraging. Character: understanding, guiding, full of positive energy.",
|
||||
"text": "Everyone has their own pace, don't rush. Take your time, you will definitely find your own path."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Full of energy. Pace: fast, crisp articulation. Emotion: cheerful and optimistic, energetic. Tone: strong sense of rhythm, cadence. Character: outgoing, confident, enthusiastic, full of youthful spirit.",
|
||||
"text": "Wow, this game is so cool! Let's team up and play together, I promise I'll carry you!"
|
||||
}
|
||||
],
|
||||
"presetVoiceDesigns": [
|
||||
{
|
||||
"label": "Sweet Girl",
|
||||
"instruct": "Young female, sweet and bright voice, with a touch of girlish charm. High pitch, lively and varied intonation. Moderate pace, clear articulation. Cheerful and relaxed emotion, full of youthful energy. Suitable for: customer service, voice assistant, entertainment content.",
|
||||
"text": "Hello, I'm happy to help you! How may I assist you today?"
|
||||
},
|
||||
{
|
||||
"label": "Mature Woman",
|
||||
"instruct": "Mature and intellectual female voice, warm and full tone, with professional woman's capable temperament. Medium pitch, stable range. Moderate to fast pace, clear and organized. Calm and confident emotion, conveying professionalism and reliability.",
|
||||
"text": "According to the latest market analysis report, this quarter's performance shows steady growth, with all indicators meeting expected targets."
|
||||
},
|
||||
{
|
||||
"label": "Magnetic Male",
|
||||
"instruct": "Mid-low male voice, deep and magnetic tone, very appealing. Slow pace, steady rhythm. Moderate volume, thick and powerful voice. Suitable for emotional content, storytelling, brand promotion.",
|
||||
"text": "Night falls, yet the city lights remain brilliant. Under each light, there's a story about dreams."
|
||||
},
|
||||
{
|
||||
"label": "Energetic Youth",
|
||||
"instruct": "Energetic young male, bright and clear tone, with youthful vigor. Fast pace, strong sense of rhythm. Enthusiastic and positive emotion, very appealing. Suitable for sports, gaming, entertainment.",
|
||||
"text": "Brothers, are you ready? Today we're going to challenge the new dungeon, let's go!"
|
||||
},
|
||||
{
|
||||
"label": "Authority Expert",
|
||||
"instruct": "Middle-aged male expert image, calm and authoritative tone, thick and powerful voice. Moderate pace, clear and standard articulation. Serious and professional emotion, conveying trust and expertise. Suitable for academic lectures, knowledge popularization, formal occasions.",
|
||||
"text": "From a historical development perspective, technological innovation has always been the core driving force for social progress."
|
||||
},
|
||||
{
|
||||
"label": "Gentle Mother",
|
||||
"instruct": "Gentle and loving middle-aged female, soft and warm tone, full of maternal care. Slow pace, calm and soothing tone. Warm and caring emotion, giving a sense of security. Suitable for children's content, emotional companionship, bedtime stories.",
|
||||
"text": "Sweetie, it's time to sleep. Mom will tell you a story. Once upon a time, there was a little rabbit who lived in the forest..."
|
||||
},
|
||||
{
|
||||
"label": "Broadcasting Host",
|
||||
"instruct": "Professional broadcasting host voice, full and round tone, standard pronunciation. Medium pitch, wide range. Standard pace, precise rhythm control. Professional and calm emotion, clear articulation. Suitable for news broadcasting, program hosting, formal reading.",
|
||||
"text": "Hello dear listeners, welcome to today's program. Next, we bring you today's news."
|
||||
},
|
||||
{
|
||||
"label": "Playful Girl",
|
||||
"instruct": "Playful and cute girl voice, light and lively sound, with unique girlish liveliness. High and varied tone, with coquettish and cute elements. Varying pace, clear articulation with cute interjections.",
|
||||
"text": "Oh no, I didn't mean to~ Can you forgive me this time? Please please~"
|
||||
}
|
||||
],
|
||||
"presetRefTexts": [
|
||||
{
|
||||
"label": "Natural Life",
|
||||
"text": "In this fast-paced world, we're always rushing forward, forgetting to pause and listen to our inner voice. Life is not just about the busyness before us, but also the poetry in the distance and the little moments of happiness we discover. May this recording bring you a touch of gentleness and strength, like an afternoon breeze. No matter how the future changes, remember to keep your love for life and embrace every bright tomorrow."
|
||||
},
|
||||
{
|
||||
"label": "Professional Formal",
|
||||
"text": "Technological progress allows us to transcend the boundaries of time and space, continuing emotions and memories through digitalization. Voice cloning is not only precise code logic, but also a bridge connecting humanity with future intelligence. Through the continuous evolution of deep learning and neural networks, every subtle intonation can be accurately captured. Let us witness together how technology gives voice a more vital expression."
|
||||
},
|
||||
{
|
||||
"label": "Literary Narrative",
|
||||
"text": "The spring breeze brushes the willow tips, carrying the fragrance of earth and news of blooming flowers. Have you ever anticipated meeting your long-lost self at some street corner? Whether it's hearty laughter or low whispers, each voice is a unique mark of life. Let us record this moment, let memories flow in the sound, becoming an eternal melody."
|
||||
}
|
||||
],
|
||||
"uiLanguages": {
|
||||
"zh-CN": "Simplified Chinese",
|
||||
"zh-TW": "Traditional Chinese",
|
||||
"en-US": "English",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean"
|
||||
},
|
||||
"uiLanguagesShort": {
|
||||
"zh-CN": "ZH-CN",
|
||||
"zh-TW": "ZH-TW",
|
||||
"en-US": "EN",
|
||||
"ja-JP": "JA",
|
||||
"ko-KR": "KO"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user