liumaolin commited on
Commit
99341ef
·
1 Parent(s): 8db3e96

feat(frontend): implement Voice Clone training task management

Browse files

- Refactor VoiceClone page to task list view with pagination
- Add training components: CreateTaskDialog, TaskCard, TaskList,
TaskProgress, TrainingLogs, UploadZone
- Add TaskDetail page for real-time progress and logs display
- Implement training API module with SSE progress subscription
- Add training store with task state management and SSE handlers
- Extend TypeScript types for TaskResponse, ProgressEvent, FileMetadata
- Update i18n locales with training-related translations (zh-CN/en-US)
- Add /clone/:taskId route for task detail navigation

Files changed (37) hide show
  1. tts-voice-app/.gitignore +35 -0
  2. tts-voice-app/electron.vite.config.ts +27 -0
  3. tts-voice-app/package-lock.json +0 -0
  4. tts-voice-app/package.json +37 -0
  5. tts-voice-app/src/main/index.ts +62 -0
  6. tts-voice-app/src/preload/index.d.ts +10 -0
  7. tts-voice-app/src/preload/index.ts +23 -0
  8. tts-voice-app/src/renderer/index.html +12 -0
  9. tts-voice-app/src/renderer/src/App.vue +36 -0
  10. tts-voice-app/src/renderer/src/api/index.ts +47 -0
  11. tts-voice-app/src/renderer/src/api/training.ts +84 -0
  12. tts-voice-app/src/renderer/src/components/layout/Sidebar.vue +200 -0
  13. tts-voice-app/src/renderer/src/components/training/CreateTaskDialog.vue +267 -0
  14. tts-voice-app/src/renderer/src/components/training/TaskCard.vue +263 -0
  15. tts-voice-app/src/renderer/src/components/training/TaskList.vue +166 -0
  16. tts-voice-app/src/renderer/src/components/training/TaskProgress.vue +293 -0
  17. tts-voice-app/src/renderer/src/components/training/TrainingLogs.vue +228 -0
  18. tts-voice-app/src/renderer/src/components/training/UploadZone.vue +404 -0
  19. tts-voice-app/src/renderer/src/components/training/index.ts +10 -0
  20. tts-voice-app/src/renderer/src/env.d.ts +7 -0
  21. tts-voice-app/src/renderer/src/i18n/index.ts +17 -0
  22. tts-voice-app/src/renderer/src/locales/en-US.json +101 -0
  23. tts-voice-app/src/renderer/src/locales/zh-CN.json +101 -0
  24. tts-voice-app/src/renderer/src/main.ts +19 -0
  25. tts-voice-app/src/renderer/src/router/index.ts +40 -0
  26. tts-voice-app/src/renderer/src/stores/app.ts +33 -0
  27. tts-voice-app/src/renderer/src/stores/training.ts +430 -0
  28. tts-voice-app/src/renderer/src/styles/global.scss +43 -0
  29. tts-voice-app/src/renderer/src/styles/reset.scss +67 -0
  30. tts-voice-app/src/renderer/src/styles/variables.scss +47 -0
  31. tts-voice-app/src/renderer/src/types/index.ts +274 -0
  32. tts-voice-app/src/renderer/src/views/TaskDetail.vue +373 -0
  33. tts-voice-app/src/renderer/src/views/TextToSpeech.vue +165 -0
  34. tts-voice-app/src/renderer/src/views/VoiceClone.vue +173 -0
  35. tts-voice-app/src/renderer/src/views/VoiceLibrary.vue +164 -0
  36. tts-voice-app/tsconfig.json +30 -0
  37. tts-voice-app/tsconfig.node.json +11 -0
tts-voice-app/.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ # Dependencies
11
+ node_modules
12
+ .npm
13
+
14
+ # Build outputs
15
+ dist
16
+ dist-ssr
17
+ out
18
+ *.local
19
+
20
+ # Editor directories and files
21
+ .vscode/*
22
+ !.vscode/extensions.json
23
+ .idea
24
+ .DS_Store
25
+ *.suo
26
+ *.ntvs*
27
+ *.njsproj
28
+ *.sln
29
+ *.sw?
30
+
31
+ # Electron
32
+ release
33
+
34
+ # TypeScript cache
35
+ *.tsbuildinfo
tts-voice-app/electron.vite.config.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolve } from 'path'
2
+ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
3
+ import vue from '@vitejs/plugin-vue'
4
+
5
+ export default defineConfig({
6
+ main: {
7
+ plugins: [externalizeDepsPlugin()]
8
+ },
9
+ preload: {
10
+ plugins: [externalizeDepsPlugin()]
11
+ },
12
+ renderer: {
13
+ resolve: {
14
+ alias: {
15
+ '@': resolve('src/renderer/src')
16
+ }
17
+ },
18
+ plugins: [vue()],
19
+ css: {
20
+ preprocessorOptions: {
21
+ scss: {
22
+ additionalData: `@use "@/styles/variables.scss" as *;`
23
+ }
24
+ }
25
+ }
26
+ }
27
+ })
tts-voice-app/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
tts-voice-app/package.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "tts-voice-app",
3
+ "version": "1.0.0",
4
+ "description": "TTS Voice Desktop Application",
5
+ "main": "./out/main/index.js",
6
+ "author": "",
7
+ "license": "MIT",
8
+ "scripts": {
9
+ "dev": "electron-vite dev",
10
+ "build": "electron-vite build",
11
+ "preview": "electron-vite preview",
12
+ "build:win": "npm run build && electron-builder --win --config",
13
+ "build:mac": "npm run build && electron-builder --mac --config",
14
+ "build:linux": "npm run build && electron-builder --linux --config"
15
+ },
16
+ "dependencies": {
17
+ "vue": "^3.4.0",
18
+ "vue-router": "^4.2.5",
19
+ "pinia": "^2.1.7",
20
+ "element-plus": "^2.5.0",
21
+ "axios": "^1.6.0",
22
+ "vue-i18n": "^9.9.0"
23
+ },
24
+ "devDependencies": {
25
+ "@electron-toolkit/preload": "^3.0.0",
26
+ "@electron-toolkit/utils": "^3.0.0",
27
+ "@vitejs/plugin-vue": "^5.0.0",
28
+ "electron": "^28.0.0",
29
+ "electron-vite": "^2.0.0",
30
+ "electron-builder": "^24.9.1",
31
+ "sass": "^1.69.0",
32
+ "typescript": "^5.3.0",
33
+ "vite": "^5.0.0",
34
+ "vue-tsc": "^1.8.0",
35
+ "@types/node": "^20.10.0"
36
+ }
37
+ }
tts-voice-app/src/main/index.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app, shell, BrowserWindow, ipcMain } from 'electron'
2
+ import { join } from 'path'
3
+ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
4
+
5
+ function createWindow(): void {
6
+ const mainWindow = new BrowserWindow({
7
+ width: 1200,
8
+ height: 800,
9
+ minWidth: 900,
10
+ minHeight: 600,
11
+ show: false,
12
+ autoHideMenuBar: true,
13
+ titleBarStyle: 'hiddenInset',
14
+ webPreferences: {
15
+ preload: join(__dirname, '../preload/index.js'),
16
+ sandbox: false,
17
+ nodeIntegration: false,
18
+ contextIsolation: true
19
+ }
20
+ })
21
+
22
+ mainWindow.on('ready-to-show', () => {
23
+ mainWindow.show()
24
+ })
25
+
26
+ mainWindow.webContents.setWindowOpenHandler((details) => {
27
+ shell.openExternal(details.url)
28
+ return { action: 'deny' }
29
+ })
30
+
31
+ // HMR for renderer base on electron-vite cli.
32
+ if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
33
+ mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
34
+ } else {
35
+ mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
36
+ }
37
+ }
38
+
39
+ app.whenReady().then(() => {
40
+ electronApp.setAppUserModelId('com.tts-voice')
41
+
42
+ app.on('browser-window-created', (_, window) => {
43
+ optimizer.watchWindowShortcuts(window)
44
+ })
45
+
46
+ // IPC: 获取 API 基础 URL
47
+ ipcMain.handle('get-api-base-url', () => {
48
+ return process.env['API_BASE_URL'] || 'http://localhost:8000'
49
+ })
50
+
51
+ createWindow()
52
+
53
+ app.on('activate', function () {
54
+ if (BrowserWindow.getAllWindows().length === 0) createWindow()
55
+ })
56
+ })
57
+
58
+ app.on('window-all-closed', () => {
59
+ if (process.platform !== 'darwin') {
60
+ app.quit()
61
+ }
62
+ })
tts-voice-app/src/preload/index.d.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ElectronAPI } from '@electron-toolkit/preload'
2
+
3
+ declare global {
4
+ interface Window {
5
+ electron: ElectronAPI
6
+ api: {
7
+ getApiBaseUrl: () => Promise<string>
8
+ }
9
+ }
10
+ }
tts-voice-app/src/preload/index.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { contextBridge, ipcRenderer } from 'electron'
2
+ import { electronAPI } from '@electron-toolkit/preload'
3
+
4
+ // Custom APIs for renderer
5
+ const api = {
6
+ getApiBaseUrl: (): Promise<string> => ipcRenderer.invoke('get-api-base-url')
7
+ }
8
+
9
+ // Use `contextBridge` APIs to expose Electron APIs to
10
+ // renderer only if context isolation is enabled
11
+ if (process.contextIsolated) {
12
+ try {
13
+ contextBridge.exposeInMainWorld('electron', electronAPI)
14
+ contextBridge.exposeInMainWorld('api', api)
15
+ } catch (error) {
16
+ console.error(error)
17
+ }
18
+ } else {
19
+ // @ts-ignore (define in dts)
20
+ window.electron = electronAPI
21
+ // @ts-ignore (define in dts)
22
+ window.api = api
23
+ }
tts-voice-app/src/renderer/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>TTS Voice</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
tts-voice-app/src/renderer/src/App.vue ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ import { onMounted } from 'vue'
3
+ import Sidebar from './components/layout/Sidebar.vue'
4
+ import { useAppStore } from './stores/app'
5
+
6
+ const appStore = useAppStore()
7
+
8
+ onMounted(() => {
9
+ // 初始化主题
10
+ document.documentElement.setAttribute('data-theme', appStore.theme)
11
+ })
12
+ </script>
13
+
14
+ <template>
15
+ <div class="app-container">
16
+ <Sidebar />
17
+ <main class="main-content">
18
+ <router-view />
19
+ </main>
20
+ </div>
21
+ </template>
22
+
23
+ <style lang="scss" scoped>
24
+ .app-container {
25
+ display: flex;
26
+ height: 100vh;
27
+ background-color: var(--bg-primary);
28
+ color: var(--text-primary);
29
+ }
30
+
31
+ .main-content {
32
+ flex: 1;
33
+ overflow: auto;
34
+ padding: 24px;
35
+ }
36
+ </style>
tts-voice-app/src/renderer/src/api/index.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ // 获取 API 基础 URL
4
+ const getBaseUrl = async (): Promise<string> => {
5
+ if (window.api?.getApiBaseUrl) {
6
+ return await window.api.getApiBaseUrl()
7
+ }
8
+ return 'http://localhost:8000'
9
+ }
10
+
11
+ // 创建 axios 实例
12
+ export const api = axios.create({
13
+ baseURL: 'http://localhost:8000/api/v1',
14
+ timeout: 30000,
15
+ headers: {
16
+ 'Content-Type': 'application/json'
17
+ }
18
+ })
19
+
20
+ // 初始化 baseURL
21
+ ;(async () => {
22
+ const baseUrl = await getBaseUrl()
23
+ api.defaults.baseURL = `${baseUrl}/api/v1`
24
+ })()
25
+
26
+ // 请求拦截器
27
+ api.interceptors.request.use(
28
+ (config) => {
29
+ // 可添加 token 等
30
+ return config
31
+ },
32
+ (error) => {
33
+ return Promise.reject(error)
34
+ }
35
+ )
36
+
37
+ // 响应拦截器
38
+ api.interceptors.response.use(
39
+ (response) => response.data,
40
+ (error) => {
41
+ const message = error.response?.data?.detail || error.message
42
+ console.error('API Error:', message)
43
+ return Promise.reject(error)
44
+ }
45
+ )
46
+
47
+ export default api
tts-voice-app/src/renderer/src/api/training.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 训练任务 API
3
+ *
4
+ * Quick Mode 一键训练 API 接口
5
+ */
6
+
7
+ import { api } from './index'
8
+ import type {
9
+ QuickModeRequest,
10
+ TaskResponse,
11
+ TaskListResponse,
12
+ FileUploadResponse,
13
+ SuccessResponse
14
+ } from '../types'
15
+
16
+ /**
17
+ * 上传音频文件
18
+ */
19
+ export async function uploadAudio(file: File): Promise<FileUploadResponse> {
20
+ const formData = new FormData()
21
+ formData.append('file', file)
22
+
23
+ return api.post('/files?purpose=training', formData, {
24
+ headers: {
25
+ 'Content-Type': 'multipart/form-data'
26
+ }
27
+ })
28
+ }
29
+
30
+ /**
31
+ * 创建训练任务
32
+ */
33
+ export async function createTask(request: QuickModeRequest): Promise<TaskResponse> {
34
+ return api.post('/tasks', request)
35
+ }
36
+
37
+ /**
38
+ * 获取任务列表
39
+ */
40
+ export async function listTasks(params: {
41
+ status?: string
42
+ limit?: number
43
+ offset?: number
44
+ }): Promise<TaskListResponse> {
45
+ return api.get('/tasks', { params })
46
+ }
47
+
48
+ /**
49
+ * 获取任务详情
50
+ */
51
+ export async function getTask(taskId: string): Promise<TaskResponse> {
52
+ return api.get(`/tasks/${taskId}`)
53
+ }
54
+
55
+ /**
56
+ * 取消任务
57
+ */
58
+ export async function cancelTask(taskId: string): Promise<SuccessResponse> {
59
+ return api.delete(`/tasks/${taskId}`)
60
+ }
61
+
62
+ /**
63
+ * SSE 进度订阅
64
+ *
65
+ * 返回 EventSource 实例,调用方需要自行管理事件监听和关闭
66
+ */
67
+ export function subscribeProgress(taskId: string): EventSource {
68
+ const baseUrl = api.defaults.baseURL || 'http://localhost:8000/api/v1'
69
+ return new EventSource(`${baseUrl}/tasks/${taskId}/progress`)
70
+ }
71
+
72
+ /**
73
+ * 训练 API 导出
74
+ */
75
+ export const trainingApi = {
76
+ uploadAudio,
77
+ createTask,
78
+ listTasks,
79
+ getTask,
80
+ cancelTask,
81
+ subscribeProgress
82
+ }
83
+
84
+ export default trainingApi
tts-voice-app/src/renderer/src/components/layout/Sidebar.vue ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useI18n } from 'vue-i18n'
5
+ import { useAppStore } from '@/stores/app'
6
+
7
+ const route = useRoute()
8
+ const router = useRouter()
9
+ const { t } = useI18n()
10
+ const appStore = useAppStore()
11
+
12
+ const menuItems = computed(() => [
13
+ { path: '/tts', icon: 'text', label: t('nav.textToSpeech') },
14
+ { path: '/library', icon: 'mic', label: t('nav.voiceLibrary') },
15
+ { path: '/clone', icon: 'copy', label: t('nav.voiceClone') }
16
+ ])
17
+
18
+ const navigateTo = (path: string) => {
19
+ router.push(path)
20
+ }
21
+
22
+ const isActive = (path: string) => {
23
+ return route.path === path
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <aside class="sidebar">
29
+ <div class="sidebar-header">
30
+ <div class="logo">
31
+ <span class="logo-icon">🎙</span>
32
+ <span class="logo-text">TTS Voice</span>
33
+ </div>
34
+ <button class="theme-toggle" @click="appStore.toggleTheme" :title="appStore.theme === 'light' ? t('theme.dark') : t('theme.light')">
35
+ <span v-if="appStore.theme === 'light'">🌙</span>
36
+ <span v-else>☀️</span>
37
+ </button>
38
+ </div>
39
+
40
+ <nav class="sidebar-nav">
41
+ <button
42
+ v-for="item in menuItems"
43
+ :key="item.path"
44
+ class="nav-item"
45
+ :class="{ active: isActive(item.path) }"
46
+ @click="navigateTo(item.path)"
47
+ >
48
+ <span class="nav-icon">
49
+ <template v-if="item.icon === 'text'">📝</template>
50
+ <template v-else-if="item.icon === 'mic'">🎤</template>
51
+ <template v-else-if="item.icon === 'copy'">📋</template>
52
+ </span>
53
+ <span class="nav-label">{{ item.label }}</span>
54
+ </button>
55
+ </nav>
56
+
57
+ <div class="sidebar-footer">
58
+ <div class="user-info">
59
+ <div class="user-avatar">
60
+ <span>U</span>
61
+ </div>
62
+ <div class="user-details">
63
+ <span class="user-name">User</span>
64
+ <span class="user-email">user@example.com</span>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </aside>
69
+ </template>
70
+
71
+ <style lang="scss" scoped>
72
+ .sidebar {
73
+ width: var(--sidebar-width);
74
+ height: 100vh;
75
+ background: var(--bg-sidebar);
76
+ display: flex;
77
+ flex-direction: column;
78
+ color: #e5eaf3;
79
+ padding-top: 28px; // 为 macOS 窗口控制按钮留出空间
80
+ }
81
+
82
+ .sidebar-header {
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ padding: 20px;
87
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
88
+ }
89
+
90
+ .logo {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 10px;
94
+ }
95
+
96
+ .logo-icon {
97
+ font-size: 24px;
98
+ }
99
+
100
+ .logo-text {
101
+ font-size: 18px;
102
+ font-weight: 600;
103
+ letter-spacing: -0.5px;
104
+ }
105
+
106
+ .theme-toggle {
107
+ width: 36px;
108
+ height: 36px;
109
+ border-radius: 8px;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ font-size: 18px;
114
+ background: rgba(255, 255, 255, 0.1);
115
+ transition: background var(--transition-fast);
116
+
117
+ &:hover {
118
+ background: rgba(255, 255, 255, 0.15);
119
+ }
120
+ }
121
+
122
+ .sidebar-nav {
123
+ flex: 1;
124
+ padding: 16px 12px;
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 4px;
128
+ }
129
+
130
+ .nav-item {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 12px;
134
+ padding: 12px 16px;
135
+ border-radius: 10px;
136
+ color: rgba(255, 255, 255, 0.7);
137
+ transition: all var(--transition-fast);
138
+ width: 100%;
139
+ text-align: left;
140
+
141
+ &:hover {
142
+ background: rgba(255, 255, 255, 0.08);
143
+ color: #fff;
144
+ }
145
+
146
+ &.active {
147
+ background: var(--primary-color);
148
+ color: #fff;
149
+ }
150
+ }
151
+
152
+ .nav-icon {
153
+ font-size: 18px;
154
+ width: 24px;
155
+ text-align: center;
156
+ }
157
+
158
+ .nav-label {
159
+ font-size: 14px;
160
+ font-weight: 500;
161
+ }
162
+
163
+ .sidebar-footer {
164
+ padding: 16px;
165
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
166
+ }
167
+
168
+ .user-info {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 12px;
172
+ }
173
+
174
+ .user-avatar {
175
+ width: 40px;
176
+ height: 40px;
177
+ border-radius: 50%;
178
+ background: var(--primary-color);
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ font-weight: 600;
183
+ font-size: 16px;
184
+ }
185
+
186
+ .user-details {
187
+ display: flex;
188
+ flex-direction: column;
189
+ }
190
+
191
+ .user-name {
192
+ font-size: 14px;
193
+ font-weight: 500;
194
+ }
195
+
196
+ .user-email {
197
+ font-size: 12px;
198
+ color: rgba(255, 255, 255, 0.5);
199
+ }
200
+ </style>
tts-voice-app/src/renderer/src/components/training/CreateTaskDialog.vue ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 创建训练任务对话框
4
+ *
5
+ * 包含音频上传和训练配置
6
+ */
7
+
8
+ import { ref, computed, watch } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import { ElMessage } from 'element-plus'
11
+ import UploadZone from './UploadZone.vue'
12
+ import { useTrainingStore } from '../../stores/training'
13
+ import type { AudioSource, TrainingQuality, QuickModeRequest } from '../../types'
14
+
15
+ const { t } = useI18n()
16
+ const store = useTrainingStore()
17
+
18
+ // Props
19
+ const props = defineProps<{
20
+ modelValue: boolean
21
+ }>()
22
+
23
+ // Emits
24
+ const emit = defineEmits<{
25
+ 'update:modelValue': [value: boolean]
26
+ created: [taskId: string]
27
+ }>()
28
+
29
+ // 表单数据
30
+ const voiceName = ref('')
31
+ const language = ref('zh')
32
+ const quality = ref<TrainingQuality>('standard')
33
+ const audioSource = ref<AudioSource | null>(null)
34
+ const uploadFailed = ref(false)
35
+
36
+ // 语言选项
37
+ const languageOptions = [
38
+ { label: '中文', value: 'zh' },
39
+ { label: 'English', value: 'en' },
40
+ { label: '日本語', value: 'ja' },
41
+ { label: '한국어', value: 'ko' }
42
+ ]
43
+
44
+ // 计算属性
45
+ const visible = computed({
46
+ get: () => props.modelValue,
47
+ set: (val) => emit('update:modelValue', val)
48
+ })
49
+
50
+ const canSubmit = computed(() => {
51
+ return (
52
+ voiceName.value.trim().length > 0 &&
53
+ audioSource.value?.fileId &&
54
+ !store.creating &&
55
+ !store.uploading
56
+ )
57
+ })
58
+
59
+ const isLoading = computed(() => store.creating || store.uploading)
60
+
61
+ // 重置表单
62
+ function resetForm() {
63
+ voiceName.value = ''
64
+ language.value = 'zh'
65
+ quality.value = 'standard'
66
+ audioSource.value = null
67
+ uploadFailed.value = false
68
+ store.clearUploadedAudio()
69
+ }
70
+
71
+ // 监听对话框关闭时重置表单
72
+ watch(visible, (newVal) => {
73
+ if (!newVal) {
74
+ resetForm()
75
+ }
76
+ })
77
+
78
+ // 处理文件选择
79
+ async function handleFileSelected(file: File) {
80
+ uploadFailed.value = false
81
+ try {
82
+ const fileId = await store.uploadAudio(file)
83
+ // 使用新对象替换以确保响应式更新
84
+ if (audioSource.value) {
85
+ audioSource.value = {
86
+ ...audioSource.value,
87
+ fileId,
88
+ duration: store.uploadedAudio?.duration
89
+ }
90
+ }
91
+ } catch (error) {
92
+ console.error('Upload failed:', error)
93
+ uploadFailed.value = true
94
+ ElMessage.error(t('clone.uploadFailed'))
95
+ // 不清除 audioSource,保留已选择的文件信息让用户可以看到
96
+ }
97
+ }
98
+
99
+ // 重试上传
100
+ async function retryUpload() {
101
+ if (audioSource.value?.file) {
102
+ await handleFileSelected(audioSource.value.file)
103
+ }
104
+ }
105
+
106
+ // 处理清除文件
107
+ function handleClearFile() {
108
+ audioSource.value = null
109
+ store.clearUploadedAudio()
110
+ }
111
+
112
+ // 提交表单
113
+ async function handleSubmit() {
114
+ if (!canSubmit.value) return
115
+
116
+ const request: QuickModeRequest = {
117
+ exp_name: voiceName.value.trim(),
118
+ audio_file_id: audioSource.value!.fileId!,
119
+ options: {
120
+ language: language.value,
121
+ quality: quality.value
122
+ }
123
+ }
124
+
125
+ try {
126
+ const task = await store.createTask(request)
127
+ ElMessage.success(t('clone.taskCreated'))
128
+ visible.value = false
129
+ emit('created', task.id)
130
+ } catch (error: unknown) {
131
+ const err = error as { response?: { data?: { detail?: string } } }
132
+ const message = err.response?.data?.detail || t('clone.createFailed')
133
+ ElMessage.error(message)
134
+ }
135
+ }
136
+
137
+ // 关闭对话框
138
+ function handleClose() {
139
+ visible.value = false
140
+ }
141
+ </script>
142
+
143
+ <template>
144
+ <el-dialog
145
+ v-model="visible"
146
+ :title="t('clone.createTask')"
147
+ width="560px"
148
+ :close-on-click-modal="false"
149
+ :close-on-press-escape="!isLoading"
150
+ @close="handleClose"
151
+ >
152
+ <div class="create-task-form">
153
+ <!-- Step 1: 上传音频 -->
154
+ <div class="form-section">
155
+ <h4 class="section-title">Step 1: {{ t('clone.uploadAudio') }}</h4>
156
+ <UploadZone
157
+ v-model="audioSource"
158
+ :uploading="store.uploading"
159
+ :disabled="store.creating"
160
+ :upload-failed="uploadFailed"
161
+ @file-selected="handleFileSelected"
162
+ @clear="handleClearFile"
163
+ @retry="retryUpload"
164
+ />
165
+ </div>
166
+
167
+ <!-- Step 2: 配置参数 -->
168
+ <div class="form-section">
169
+ <h4 class="section-title">Step 2: {{ t('tts.settings') }}</h4>
170
+ <el-form label-position="top" :disabled="isLoading">
171
+ <el-form-item :label="t('clone.voiceName')" required>
172
+ <el-input
173
+ v-model="voiceName"
174
+ :placeholder="t('clone.voiceNamePlaceholder')"
175
+ maxlength="100"
176
+ show-word-limit
177
+ />
178
+ </el-form-item>
179
+
180
+ <el-form-item :label="t('clone.language')">
181
+ <el-select v-model="language" style="width: 100%">
182
+ <el-option
183
+ v-for="opt in languageOptions"
184
+ :key="opt.value"
185
+ :label="opt.label"
186
+ :value="opt.value"
187
+ />
188
+ </el-select>
189
+ </el-form-item>
190
+
191
+ <el-form-item :label="t('clone.quality')">
192
+ <el-radio-group v-model="quality">
193
+ <el-radio value="fast">
194
+ {{ t('clone.qualityFast') }}
195
+ <span class="quality-hint">~10{{ t('clone.minutes') }}</span>
196
+ </el-radio>
197
+ <el-radio value="standard">
198
+ {{ t('clone.qualityStandard') }}
199
+ <span class="quality-hint">~20{{ t('clone.minutes') }}</span>
200
+ </el-radio>
201
+ <el-radio value="high">
202
+ {{ t('clone.qualityHigh') }}
203
+ <span class="quality-hint">~40{{ t('clone.minutes') }}</span>
204
+ </el-radio>
205
+ </el-radio-group>
206
+ </el-form-item>
207
+ </el-form>
208
+ </div>
209
+ </div>
210
+
211
+ <template #footer>
212
+ <div class="dialog-footer">
213
+ <el-button @click="handleClose" :disabled="isLoading">
214
+ {{ t('common.cancel') }}
215
+ </el-button>
216
+ <el-button
217
+ type="primary"
218
+ :loading="store.creating"
219
+ :disabled="!canSubmit"
220
+ @click="handleSubmit"
221
+ >
222
+ {{ t('clone.startTraining') }}
223
+ </el-button>
224
+ </div>
225
+ </template>
226
+ </el-dialog>
227
+ </template>
228
+
229
+ <style lang="scss" scoped>
230
+ .create-task-form {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 24px;
234
+ }
235
+
236
+ .form-section {
237
+ .section-title {
238
+ font-size: 14px;
239
+ font-weight: 600;
240
+ color: var(--text-primary);
241
+ margin-bottom: 12px;
242
+ }
243
+ }
244
+
245
+ .quality-hint {
246
+ font-size: 12px;
247
+ color: var(--text-muted);
248
+ margin-left: 4px;
249
+ }
250
+
251
+ .dialog-footer {
252
+ display: flex;
253
+ justify-content: flex-end;
254
+ gap: 12px;
255
+ }
256
+
257
+ :deep(.el-radio-group) {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 8px;
261
+ }
262
+
263
+ :deep(.el-radio) {
264
+ height: auto;
265
+ line-height: 1.5;
266
+ }
267
+ </style>
tts-voice-app/src/renderer/src/components/training/TaskCard.vue ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 任务卡片组件
4
+ *
5
+ * 展示任务状态、进度和操作按钮
6
+ */
7
+
8
+ import { computed } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import {
11
+ Clock,
12
+ Loading,
13
+ CircleCheck,
14
+ CircleClose,
15
+ Remove,
16
+ Warning,
17
+ Delete,
18
+ View
19
+ } from '@element-plus/icons-vue'
20
+ import type { TaskResponse, TaskStatus, ProgressEvent } from '../../types'
21
+
22
+ const { t } = useI18n()
23
+
24
+ // Props
25
+ const props = defineProps<{
26
+ task: TaskResponse
27
+ progress?: ProgressEvent
28
+ }>()
29
+
30
+ // Emits
31
+ const emit = defineEmits<{
32
+ cancel: [taskId: string]
33
+ view: [task: TaskResponse]
34
+ }>()
35
+
36
+ // 状态图标映射
37
+ const statusConfig: Record<
38
+ TaskStatus,
39
+ {
40
+ icon: typeof Clock
41
+ color: string
42
+ label: string
43
+ }
44
+ > = {
45
+ queued: { icon: Clock, color: 'var(--text-muted)', label: 'clone.status.queued' },
46
+ running: { icon: Loading, color: 'var(--primary-color)', label: 'clone.status.running' },
47
+ completed: { icon: CircleCheck, color: 'var(--success-color)', label: 'clone.status.completed' },
48
+ failed: { icon: CircleClose, color: 'var(--danger-color)', label: 'clone.status.failed' },
49
+ cancelled: { icon: Remove, color: 'var(--warning-color)', label: 'clone.status.cancelled' },
50
+ interrupted: { icon: Warning, color: 'var(--warning-color)', label: 'clone.status.interrupted' }
51
+ }
52
+
53
+ // 计算属性
54
+ const currentStatus = computed(() => statusConfig[props.task.status] || statusConfig.queued)
55
+
56
+ const isRunning = computed(() => props.task.status === 'running' || props.task.status === 'queued')
57
+
58
+ const canCancel = computed(() => props.task.status === 'running' || props.task.status === 'queued')
59
+
60
+ const progressPercent = computed(() => {
61
+ // 使用实时进度或任务自带进度
62
+ const p = props.progress?.overall_progress ?? props.task.overall_progress
63
+ return Math.round(p * 100)
64
+ })
65
+
66
+ const currentStage = computed(() => {
67
+ const stage = props.progress?.stage || props.task.current_stage
68
+ if (!stage) return ''
69
+ // 翻译阶段名称
70
+ return t(`clone.stage.${stage}`, stage)
71
+ })
72
+
73
+ const currentMessage = computed(() => {
74
+ return props.progress?.message || props.task.message || ''
75
+ })
76
+
77
+ const createdTime = computed(() => {
78
+ if (!props.task.created_at) return ''
79
+ const date = new Date(props.task.created_at)
80
+ return date.toLocaleString()
81
+ })
82
+
83
+ // 方法
84
+ function handleCancel() {
85
+ emit('cancel', props.task.id)
86
+ }
87
+
88
+ function handleView() {
89
+ emit('view', props.task)
90
+ }
91
+ </script>
92
+
93
+ <template>
94
+ <div class="task-card" :class="[`status-${task.status}`]" @click="handleView">
95
+ <!-- 左侧:状态图标 -->
96
+ <div class="task-status" :style="{ color: currentStatus.color }">
97
+ <el-icon :class="{ 'is-loading': isRunning }" :size="24">
98
+ <component :is="currentStatus.icon" />
99
+ </el-icon>
100
+ </div>
101
+
102
+ <!-- 中间:任务信息 -->
103
+ <div class="task-content">
104
+ <div class="task-header">
105
+ <span class="task-name">{{ task.exp_name }}</span>
106
+ <el-tag :type="task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'cancelled' ? 'warning' : 'info'" size="small">
107
+ {{ t(currentStatus.label) }}
108
+ </el-tag>
109
+ </div>
110
+
111
+ <!-- 进度条(运行中时显示) -->
112
+ <div v-if="isRunning" class="task-progress">
113
+ <el-progress
114
+ :percentage="progressPercent"
115
+ :stroke-width="6"
116
+ :show-text="false"
117
+ />
118
+ <div class="progress-info">
119
+ <span v-if="currentStage" class="stage">{{ currentStage }}</span>
120
+ <span class="percent">{{ progressPercent }}%</span>
121
+ </div>
122
+ <div v-if="currentMessage" class="progress-message">{{ currentMessage }}</div>
123
+ </div>
124
+
125
+ <!-- 创建时间 -->
126
+ <div class="task-meta">
127
+ <span class="created-at">{{ createdTime }}</span>
128
+ </div>
129
+ </div>
130
+
131
+ <!-- 右侧:操作按钮 -->
132
+ <div class="task-actions">
133
+ <el-button
134
+ v-if="canCancel"
135
+ type="danger"
136
+ :icon="Delete"
137
+ circle
138
+ size="small"
139
+ @click.stop="handleCancel"
140
+ />
141
+ <el-button
142
+ type="info"
143
+ :icon="View"
144
+ circle
145
+ size="small"
146
+ plain
147
+ @click.stop="handleView"
148
+ />
149
+ </div>
150
+ </div>
151
+ </template>
152
+
153
+ <style lang="scss" scoped>
154
+ .task-card {
155
+ display: flex;
156
+ align-items: flex-start;
157
+ gap: 16px;
158
+ padding: 16px 20px;
159
+ background: var(--bg-primary);
160
+ border-radius: var(--border-radius-lg);
161
+ border: 1px solid var(--border-color-light);
162
+ transition: all var(--transition-fast);
163
+ cursor: pointer;
164
+
165
+ &:hover {
166
+ border-color: var(--primary-color);
167
+ box-shadow: 0 2px 12px var(--shadow-color);
168
+ transform: translateY(-1px);
169
+ }
170
+
171
+ &.status-running,
172
+ &.status-queued {
173
+ border-left: 3px solid var(--primary-color);
174
+ }
175
+
176
+ &.status-completed {
177
+ border-left: 3px solid var(--success-color);
178
+ }
179
+
180
+ &.status-failed {
181
+ border-left: 3px solid var(--danger-color);
182
+ }
183
+
184
+ &.status-cancelled,
185
+ &.status-interrupted {
186
+ border-left: 3px solid var(--warning-color);
187
+ }
188
+ }
189
+
190
+ .task-status {
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ width: 40px;
195
+ height: 40px;
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .task-content {
200
+ flex: 1;
201
+ min-width: 0;
202
+ }
203
+
204
+ .task-header {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 12px;
208
+ margin-bottom: 8px;
209
+ }
210
+
211
+ .task-name {
212
+ font-size: 15px;
213
+ font-weight: 600;
214
+ color: var(--text-primary);
215
+ white-space: nowrap;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ }
219
+
220
+ .task-progress {
221
+ margin-bottom: 8px;
222
+
223
+ .el-progress {
224
+ margin-bottom: 4px;
225
+ }
226
+ }
227
+
228
+ .progress-info {
229
+ display: flex;
230
+ justify-content: space-between;
231
+ font-size: 12px;
232
+ color: var(--text-muted);
233
+
234
+ .stage {
235
+ color: var(--text-secondary);
236
+ }
237
+
238
+ .percent {
239
+ font-weight: 500;
240
+ color: var(--primary-color);
241
+ }
242
+ }
243
+
244
+ .progress-message {
245
+ font-size: 12px;
246
+ color: var(--text-muted);
247
+ margin-top: 4px;
248
+ white-space: nowrap;
249
+ overflow: hidden;
250
+ text-overflow: ellipsis;
251
+ }
252
+
253
+ .task-meta {
254
+ font-size: 12px;
255
+ color: var(--text-muted);
256
+ }
257
+
258
+ .task-actions {
259
+ display: flex;
260
+ gap: 8px;
261
+ flex-shrink: 0;
262
+ }
263
+ </style>
tts-voice-app/src/renderer/src/components/training/TaskList.vue ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 任务列表组件
4
+ *
5
+ * 展示任务卡片列表,支持空状态和加载状态
6
+ */
7
+
8
+ import { useI18n } from 'vue-i18n'
9
+ import { Refresh, FolderOpened } from '@element-plus/icons-vue'
10
+ import TaskCard from './TaskCard.vue'
11
+ import type { TaskResponse, ProgressEvent } from '../../types'
12
+
13
+ const { t } = useI18n()
14
+
15
+ // Props
16
+ defineProps<{
17
+ tasks: TaskResponse[]
18
+ progressMap: Record<string, ProgressEvent>
19
+ loading?: boolean
20
+ }>()
21
+
22
+ // Emits
23
+ const emit = defineEmits<{
24
+ cancel: [taskId: string]
25
+ view: [task: TaskResponse]
26
+ refresh: []
27
+ }>()
28
+
29
+ // 方法
30
+ function handleCancel(taskId: string) {
31
+ emit('cancel', taskId)
32
+ }
33
+
34
+ function handleView(task: TaskResponse) {
35
+ emit('view', task)
36
+ }
37
+
38
+ function handleRefresh() {
39
+ emit('refresh')
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <div class="task-list-wrapper">
45
+ <!-- 工具栏 -->
46
+ <div class="task-list-toolbar">
47
+ <span class="task-count">{{ t('clone.taskCount', { count: tasks.length }) }}</span>
48
+ <el-button
49
+ :icon="Refresh"
50
+ circle
51
+ size="small"
52
+ :loading="loading"
53
+ @click="handleRefresh"
54
+ />
55
+ </div>
56
+
57
+ <!-- 加载状态 -->
58
+ <div v-if="loading && tasks.length === 0" class="task-list-loading">
59
+ <el-icon class="is-loading" :size="32"><Refresh /></el-icon>
60
+ <span>{{ t('common.loading') }}</span>
61
+ </div>
62
+
63
+ <!-- 空状态 -->
64
+ <div v-else-if="tasks.length === 0" class="task-list-empty">
65
+ <el-icon :size="64"><FolderOpened /></el-icon>
66
+ <div class="empty-text">{{ t('clone.noTasks') }}</div>
67
+ <div class="empty-hint">{{ t('clone.noTasksHint') }}</div>
68
+ </div>
69
+
70
+ <!-- 任务列表 -->
71
+ <div v-else class="task-list">
72
+ <TaskCard
73
+ v-for="task in tasks"
74
+ :key="task.id"
75
+ :task="task"
76
+ :progress="progressMap[task.id]"
77
+ @cancel="handleCancel"
78
+ @view="handleView"
79
+ />
80
+ </div>
81
+ </div>
82
+ </template>
83
+
84
+ <style lang="scss" scoped>
85
+ .task-list-wrapper {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 16px;
89
+ flex: 1;
90
+ min-height: 0;
91
+ overflow: hidden;
92
+ }
93
+
94
+ .task-list-toolbar {
95
+ display: flex;
96
+ justify-content: space-between;
97
+ align-items: center;
98
+ }
99
+
100
+ .task-count {
101
+ font-size: 13px;
102
+ color: var(--text-muted);
103
+ }
104
+
105
+ .task-list {
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: 12px;
109
+ flex: 1;
110
+ min-height: 0;
111
+ overflow-y: auto;
112
+ padding-right: 4px;
113
+
114
+ // 自定义滚动条
115
+ &::-webkit-scrollbar {
116
+ width: 6px;
117
+ }
118
+
119
+ &::-webkit-scrollbar-track {
120
+ background: transparent;
121
+ }
122
+
123
+ &::-webkit-scrollbar-thumb {
124
+ background: var(--border-color);
125
+ border-radius: 3px;
126
+
127
+ &:hover {
128
+ background: var(--text-muted);
129
+ }
130
+ }
131
+ }
132
+
133
+ .task-list-loading,
134
+ .task-list-empty {
135
+ display: flex;
136
+ flex-direction: column;
137
+ align-items: center;
138
+ justify-content: center;
139
+ padding: 60px 20px;
140
+ color: var(--text-muted);
141
+ gap: 12px;
142
+ }
143
+
144
+ .task-list-loading {
145
+ .el-icon {
146
+ color: var(--primary-color);
147
+ }
148
+ }
149
+
150
+ .task-list-empty {
151
+ .el-icon {
152
+ opacity: 0.5;
153
+ }
154
+
155
+ .empty-text {
156
+ font-size: 15px;
157
+ color: var(--text-secondary);
158
+ margin-top: 8px;
159
+ }
160
+
161
+ .empty-hint {
162
+ font-size: 13px;
163
+ color: var(--text-muted);
164
+ }
165
+ }
166
+ </style>
tts-voice-app/src/renderer/src/components/training/TaskProgress.vue ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 任务进度详情组件
4
+ *
5
+ * 展示训练各阶段的进度状态
6
+ */
7
+
8
+ import { computed } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import { CircleCheck, Loading, Clock, CircleClose } from '@element-plus/icons-vue'
11
+ import type { TaskResponse, ProgressEvent, StageType } from '../../types'
12
+ import { TRAINING_STAGES } from '../../types'
13
+
14
+ const { t } = useI18n()
15
+
16
+ // Props
17
+ const props = defineProps<{
18
+ task: TaskResponse
19
+ progress?: ProgressEvent
20
+ }>()
21
+
22
+ // 阶段状态类型
23
+ type StageStatus = 'pending' | 'running' | 'completed' | 'failed'
24
+
25
+ // 后端阶段名称(中文)到类型标识符的映射
26
+ const stageNameToType: Record<string, StageType> = {
27
+ // 英文类型名(直接匹配)
28
+ 'audio_slice': 'audio_slice',
29
+ 'asr': 'asr',
30
+ 'text_feature': 'text_feature',
31
+ 'hubert_feature': 'hubert_feature',
32
+ 'semantic_token': 'semantic_token',
33
+ 'sovits_train': 'sovits_train',
34
+ 'gpt_train': 'gpt_train',
35
+ // 中文名称映射
36
+ '语音切分': 'audio_slice',
37
+ '音频切片': 'audio_slice',
38
+ '语音识别': 'asr',
39
+ '文本分词与特征提取': 'text_feature',
40
+ '文本特征': 'text_feature',
41
+ '语音自监督特征提取': 'hubert_feature',
42
+ 'HuBERT特征': 'hubert_feature',
43
+ '语义Token提取': 'semantic_token',
44
+ '语义token提取': 'semantic_token',
45
+ 'SoVITS训练': 'sovits_train',
46
+ 'GPT训练': 'gpt_train',
47
+ }
48
+
49
+ // 计算每个阶段的状态
50
+ const stageStatuses = computed(() => {
51
+ const rawStage = props.progress?.stage || props.task.current_stage
52
+ const taskStatus = props.task.status
53
+
54
+ // 定义阶段顺序
55
+ const stageOrder: StageType[] = [
56
+ 'audio_slice',
57
+ 'asr',
58
+ 'text_feature',
59
+ 'hubert_feature',
60
+ 'semantic_token',
61
+ 'sovits_train',
62
+ 'gpt_train'
63
+ ]
64
+
65
+ // 将阶段名称转换为类型标识符
66
+ const currentStageType = rawStage ? (stageNameToType[rawStage] || rawStage) : null
67
+ const currentIndex = currentStageType ? stageOrder.indexOf(currentStageType as StageType) : -1
68
+
69
+ return TRAINING_STAGES.map((stage, index) => {
70
+ let status: StageStatus = 'pending'
71
+
72
+ if (taskStatus === 'completed') {
73
+ status = 'completed'
74
+ } else if (taskStatus === 'failed') {
75
+ if (index < currentIndex) {
76
+ status = 'completed'
77
+ } else if (index === currentIndex) {
78
+ status = 'failed'
79
+ } else {
80
+ status = 'pending'
81
+ }
82
+ } else if (taskStatus === 'running' || taskStatus === 'queued') {
83
+ if (index < currentIndex) {
84
+ status = 'completed'
85
+ } else if (index === currentIndex) {
86
+ status = 'running'
87
+ } else {
88
+ status = 'pending'
89
+ }
90
+ } else if (taskStatus === 'cancelled' || taskStatus === 'interrupted') {
91
+ if (index < currentIndex) {
92
+ status = 'completed'
93
+ } else {
94
+ status = 'pending'
95
+ }
96
+ }
97
+
98
+ return {
99
+ ...stage,
100
+ status,
101
+ label: t(`clone.stage.${stage.type}`, stage.name)
102
+ }
103
+ })
104
+ })
105
+
106
+ // 计算整体进度百分比
107
+ const overallPercent = computed(() => {
108
+ const p = props.progress?.overall_progress ?? props.task.overall_progress
109
+ return Math.round(p * 100)
110
+ })
111
+
112
+ // 获取阶段图标
113
+ function getStageIcon(status: StageStatus) {
114
+ switch (status) {
115
+ case 'completed':
116
+ return CircleCheck
117
+ case 'running':
118
+ return Loading
119
+ case 'failed':
120
+ return CircleClose
121
+ default:
122
+ return Clock
123
+ }
124
+ }
125
+
126
+ // 获取阶段颜色
127
+ function getStageColor(status: StageStatus) {
128
+ switch (status) {
129
+ case 'completed':
130
+ return 'var(--success-color)'
131
+ case 'running':
132
+ return 'var(--primary-color)'
133
+ case 'failed':
134
+ return 'var(--danger-color)'
135
+ default:
136
+ return 'var(--text-muted)'
137
+ }
138
+ }
139
+ </script>
140
+
141
+ <template>
142
+ <div class="task-progress">
143
+ <!-- 整体进度 -->
144
+ <div class="overall-progress">
145
+ <div class="progress-header">
146
+ <span class="progress-label">{{ t('clone.overallProgress') }}</span>
147
+ <span class="progress-value">{{ overallPercent }}%</span>
148
+ </div>
149
+ <el-progress
150
+ :percentage="overallPercent"
151
+ :stroke-width="8"
152
+ :show-text="false"
153
+ :status="task.status === 'completed' ? 'success' : task.status === 'failed' ? 'exception' : undefined"
154
+ />
155
+ </div>
156
+
157
+ <!-- 阶段列表 -->
158
+ <div class="stage-list">
159
+ <div
160
+ v-for="stage in stageStatuses"
161
+ :key="stage.type"
162
+ class="stage-item"
163
+ :class="[`status-${stage.status}`]"
164
+ >
165
+ <el-icon
166
+ :class="{ 'is-loading': stage.status === 'running' }"
167
+ :style="{ color: getStageColor(stage.status) }"
168
+ :size="18"
169
+ >
170
+ <component :is="getStageIcon(stage.status)" />
171
+ </el-icon>
172
+ <span class="stage-type">{{ stage.type }}</span>
173
+ <span class="stage-label">{{ stage.label }}</span>
174
+ <span v-if="stage.status === 'completed'" class="stage-value">100%</span>
175
+ <span v-else-if="stage.status === 'running'" class="stage-value running">
176
+ {{ Math.round((progress?.progress ?? task.progress) * 100) }}%
177
+ </span>
178
+ <span v-else class="stage-value">0%</span>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- 当前消息 -->
183
+ <div v-if="progress?.message || task.message" class="current-message">
184
+ {{ progress?.message || task.message }}
185
+ </div>
186
+
187
+ <!-- 错误消息 -->
188
+ <div v-if="task.error_message" class="error-message">
189
+ <el-icon><CircleClose /></el-icon>
190
+ {{ task.error_message }}
191
+ </div>
192
+ </div>
193
+ </template>
194
+
195
+ <style lang="scss" scoped>
196
+ .task-progress {
197
+ display: flex;
198
+ flex-direction: column;
199
+ gap: 20px;
200
+ }
201
+
202
+ .overall-progress {
203
+ .progress-header {
204
+ display: flex;
205
+ justify-content: space-between;
206
+ margin-bottom: 8px;
207
+ }
208
+
209
+ .progress-label {
210
+ font-size: 13px;
211
+ color: var(--text-secondary);
212
+ }
213
+
214
+ .progress-value {
215
+ font-size: 15px;
216
+ font-weight: 600;
217
+ color: var(--primary-color);
218
+ }
219
+ }
220
+
221
+ .stage-list {
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: 8px;
225
+ }
226
+
227
+ .stage-item {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 12px;
231
+ padding: 12px 16px;
232
+ background: var(--bg-primary);
233
+ border-radius: var(--border-radius);
234
+ transition: all var(--transition-fast);
235
+
236
+ &.status-running {
237
+ background: var(--bg-secondary);
238
+ border-left: 3px solid var(--primary-color);
239
+ }
240
+
241
+ &.status-completed {
242
+ opacity: 0.8;
243
+ }
244
+
245
+ .el-icon {
246
+ flex-shrink: 0;
247
+ }
248
+ }
249
+
250
+ .stage-type {
251
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
252
+ font-size: 12px;
253
+ color: var(--text-muted);
254
+ min-width: 120px;
255
+ }
256
+
257
+ .stage-label {
258
+ flex: 1;
259
+ font-size: 14px;
260
+ color: var(--text-primary);
261
+ }
262
+
263
+ .stage-value {
264
+ font-size: 13px;
265
+ font-weight: 500;
266
+ color: var(--text-muted);
267
+ min-width: 40px;
268
+ text-align: right;
269
+
270
+ &.running {
271
+ color: var(--primary-color);
272
+ }
273
+ }
274
+
275
+ .current-message {
276
+ font-size: 13px;
277
+ color: var(--text-secondary);
278
+ padding: 12px 16px;
279
+ background: var(--bg-secondary);
280
+ border-radius: var(--border-radius);
281
+ }
282
+
283
+ .error-message {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 8px;
287
+ font-size: 13px;
288
+ color: var(--danger-color);
289
+ padding: 12px 16px;
290
+ background: rgba(239, 68, 68, 0.1);
291
+ border-radius: var(--border-radius);
292
+ }
293
+ </style>
tts-voice-app/src/renderer/src/components/training/TrainingLogs.vue ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 训练日志展示组件
4
+ *
5
+ * 展示训练过程中的实时日志
6
+ */
7
+
8
+ import { ref, watch, nextTick } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import type { LogEntry } from '../../types'
11
+
12
+ const { t } = useI18n()
13
+
14
+ // Props
15
+ const props = defineProps<{
16
+ logs: LogEntry[]
17
+ autoScroll?: boolean
18
+ }>()
19
+
20
+ // 默认值
21
+ const autoScrollEnabled = ref(props.autoScroll ?? true)
22
+
23
+ // Refs
24
+ const logsContainer = ref<HTMLElement | null>(null)
25
+
26
+ // 自动滚动到底部
27
+ watch(
28
+ () => props.logs.length,
29
+ async () => {
30
+ if (autoScrollEnabled.value && logsContainer.value) {
31
+ await nextTick()
32
+ logsContainer.value.scrollTop = logsContainer.value.scrollHeight
33
+ }
34
+ }
35
+ )
36
+
37
+ // 获取日志级别的样式类
38
+ function getLogClass(level: LogEntry['level']) {
39
+ return `log-${level}`
40
+ }
41
+
42
+ // 获取日志级别的图标
43
+ function getLogIcon(level: LogEntry['level']) {
44
+ switch (level) {
45
+ case 'error':
46
+ return '✕'
47
+ case 'warning':
48
+ return '⚠'
49
+ default:
50
+ return '›'
51
+ }
52
+ }
53
+
54
+ // 格式化时间戳
55
+ function formatTime(timestamp: string) {
56
+ try {
57
+ const date = new Date(timestamp)
58
+ return date.toLocaleTimeString('zh-CN', {
59
+ hour: '2-digit',
60
+ minute: '2-digit',
61
+ second: '2-digit'
62
+ })
63
+ } catch {
64
+ return timestamp
65
+ }
66
+ }
67
+
68
+ // 切换自动滚动
69
+ function toggleAutoScroll() {
70
+ autoScrollEnabled.value = !autoScrollEnabled.value
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <div class="training-logs">
76
+ <!-- 日志头部 -->
77
+ <div class="logs-header">
78
+ <span class="logs-title">{{ t('clone.detail.logs') }}</span>
79
+ <div class="logs-actions">
80
+ <el-button
81
+ :type="autoScrollEnabled ? 'primary' : 'default'"
82
+ size="small"
83
+ text
84
+ @click="toggleAutoScroll"
85
+ >
86
+ {{ autoScrollEnabled ? '自动滚动: 开' : '自动滚动: 关' }}
87
+ </el-button>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- 日志内容 -->
92
+ <div ref="logsContainer" class="logs-container">
93
+ <div v-if="logs.length === 0" class="logs-empty">
94
+ {{ t('clone.detail.noLogs') }}
95
+ </div>
96
+
97
+ <div
98
+ v-for="(log, index) in logs"
99
+ :key="index"
100
+ class="log-entry"
101
+ :class="getLogClass(log.level)"
102
+ >
103
+ <span class="log-icon">{{ getLogIcon(log.level) }}</span>
104
+ <span class="log-time">{{ formatTime(log.timestamp) }}</span>
105
+ <span v-if="log.stage" class="log-stage">[{{ log.stage }}]</span>
106
+ <span class="log-message">{{ log.message }}</span>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </template>
111
+
112
+ <style lang="scss" scoped>
113
+ .training-logs {
114
+ display: flex;
115
+ flex-direction: column;
116
+ height: 100%;
117
+ background: var(--bg-primary);
118
+ border-radius: var(--border-radius-lg);
119
+ border: 1px solid var(--border-color-light);
120
+ overflow: hidden;
121
+ }
122
+
123
+ .logs-header {
124
+ display: flex;
125
+ justify-content: space-between;
126
+ align-items: center;
127
+ padding: 12px 16px;
128
+ border-bottom: 1px solid var(--border-color-light);
129
+ flex-shrink: 0;
130
+ }
131
+
132
+ .logs-title {
133
+ font-size: 14px;
134
+ font-weight: 600;
135
+ color: var(--text-primary);
136
+ }
137
+
138
+ .logs-container {
139
+ flex: 1;
140
+ overflow-y: auto;
141
+ padding: 12px;
142
+ font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
143
+ font-size: 12px;
144
+ line-height: 1.6;
145
+ background: var(--bg-secondary);
146
+ }
147
+
148
+ .logs-empty {
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ height: 100%;
153
+ color: var(--text-muted);
154
+ font-family: inherit;
155
+ }
156
+
157
+ .log-entry {
158
+ display: flex;
159
+ align-items: flex-start;
160
+ gap: 8px;
161
+ padding: 4px 8px;
162
+ border-radius: 4px;
163
+ transition: background-color var(--transition-fast);
164
+
165
+ &:hover {
166
+ background: rgba(0, 0, 0, 0.05);
167
+ }
168
+
169
+ &.log-info {
170
+ color: var(--text-secondary);
171
+
172
+ .log-icon {
173
+ color: var(--primary-color);
174
+ }
175
+ }
176
+
177
+ &.log-warning {
178
+ color: var(--warning-color);
179
+
180
+ .log-icon {
181
+ color: var(--warning-color);
182
+ }
183
+ }
184
+
185
+ &.log-error {
186
+ color: var(--danger-color);
187
+
188
+ .log-icon {
189
+ color: var(--danger-color);
190
+ }
191
+ }
192
+ }
193
+
194
+ .log-icon {
195
+ flex-shrink: 0;
196
+ width: 14px;
197
+ text-align: center;
198
+ font-weight: bold;
199
+ }
200
+
201
+ .log-time {
202
+ flex-shrink: 0;
203
+ color: var(--text-muted);
204
+ min-width: 70px;
205
+ }
206
+
207
+ .log-stage {
208
+ flex-shrink: 0;
209
+ color: var(--primary-color);
210
+ font-weight: 500;
211
+ }
212
+
213
+ .log-message {
214
+ flex: 1;
215
+ word-break: break-word;
216
+ }
217
+
218
+ // 深色模式适配
219
+ :root[data-theme='dark'] {
220
+ .logs-container {
221
+ background: #1a1a1a;
222
+ }
223
+
224
+ .log-entry:hover {
225
+ background: rgba(255, 255, 255, 0.05);
226
+ }
227
+ }
228
+ </style>
tts-voice-app/src/renderer/src/components/training/UploadZone.vue ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 音频上传组件
4
+ *
5
+ * 支持拖拽上传和点击选择文件
6
+ * 预留麦克风录制接口供后期扩展
7
+ */
8
+
9
+ import { ref, computed } from 'vue'
10
+ import { useI18n } from 'vue-i18n'
11
+ import { UploadFilled, Microphone, Delete, Document, CircleCheck, RefreshRight, WarningFilled } from '@element-plus/icons-vue'
12
+ import type { AudioSource } from '../../types'
13
+
14
+ const { t } = useI18n()
15
+
16
+ // Props
17
+ const props = defineProps<{
18
+ modelValue: AudioSource | null
19
+ uploading?: boolean
20
+ disabled?: boolean
21
+ uploadFailed?: boolean
22
+ }>()
23
+
24
+ // Emits
25
+ const emit = defineEmits<{
26
+ 'update:modelValue': [value: AudioSource | null]
27
+ fileSelected: [file: File]
28
+ startRecording: [] // 预留:开始录制
29
+ stopRecording: [] // 预留:停止录制
30
+ clear: []
31
+ retry: [] // 重试上传
32
+ }>()
33
+
34
+ // 状态
35
+ const isDragging = ref(false)
36
+
37
+ // 是否显示录制按钮(后期扩展)
38
+ const showRecordButton = ref(false)
39
+
40
+ // 计算属性
41
+ const hasFile = computed(() => !!props.modelValue?.file || !!props.modelValue?.fileId)
42
+
43
+ const fileName = computed(() => {
44
+ if (props.modelValue?.filename) {
45
+ return props.modelValue.filename
46
+ }
47
+ if (props.modelValue?.file) {
48
+ return props.modelValue.file.name
49
+ }
50
+ return ''
51
+ })
52
+
53
+ const fileSize = computed(() => {
54
+ const file = props.modelValue?.file
55
+ if (!file) return ''
56
+ const size = file.size
57
+ if (size < 1024) return `${size} B`
58
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
59
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`
60
+ })
61
+
62
+ const fileDuration = computed(() => {
63
+ const duration = props.modelValue?.duration
64
+ if (!duration) return ''
65
+ const minutes = Math.floor(duration / 60)
66
+ const seconds = Math.floor(duration % 60)
67
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
68
+ })
69
+
70
+ // 方法
71
+ function handleDragOver(e: DragEvent) {
72
+ e.preventDefault()
73
+ if (!props.disabled) {
74
+ isDragging.value = true
75
+ }
76
+ }
77
+
78
+ function handleDragLeave() {
79
+ isDragging.value = false
80
+ }
81
+
82
+ function handleDrop(e: DragEvent) {
83
+ e.preventDefault()
84
+ isDragging.value = false
85
+
86
+ if (props.disabled) return
87
+
88
+ const files = e.dataTransfer?.files
89
+ if (files && files.length > 0) {
90
+ handleFile(files[0])
91
+ }
92
+ }
93
+
94
+ function handleFileInput(e: Event) {
95
+ const input = e.target as HTMLInputElement
96
+ if (input.files && input.files.length > 0) {
97
+ handleFile(input.files[0])
98
+ }
99
+ // 清空 input 以便重复选择同一文件
100
+ input.value = ''
101
+ }
102
+
103
+ function handleFile(file: File) {
104
+ // 验证文件类型
105
+ const allowedTypes = ['audio/wav', 'audio/mpeg', 'audio/mp3', 'audio/flac', 'audio/x-flac', 'audio/mp4', 'audio/x-m4a', 'audio/m4a']
106
+ const allowedExts = ['.wav', '.mp3', '.flac', '.m4a']
107
+ const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'))
108
+
109
+ if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
110
+ // TODO: 显示错误提示
111
+ console.error('Unsupported file type:', file.type)
112
+ return
113
+ }
114
+
115
+ // 更新 model
116
+ const audioSource: AudioSource = {
117
+ type: 'file',
118
+ file,
119
+ filename: file.name
120
+ }
121
+ emit('update:modelValue', audioSource)
122
+ emit('fileSelected', file)
123
+ }
124
+
125
+ function clearFile() {
126
+ emit('update:modelValue', null)
127
+ emit('clear')
128
+ }
129
+
130
+ function triggerFileInput() {
131
+ const input = document.getElementById('audio-file-input') as HTMLInputElement
132
+ input?.click()
133
+ }
134
+
135
+ // 预留录制功能
136
+ function startRecording() {
137
+ emit('startRecording')
138
+ }
139
+
140
+ function stopRecording() {
141
+ emit('stopRecording')
142
+ }
143
+
144
+ // 重试上传
145
+ function retryUpload() {
146
+ emit('retry')
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <div class="upload-zone-wrapper">
152
+ <!-- 未上传状态:显示上传区域 -->
153
+ <div
154
+ v-if="!hasFile"
155
+ class="upload-zone"
156
+ :class="{
157
+ dragging: isDragging,
158
+ disabled: disabled,
159
+ uploading: uploading
160
+ }"
161
+ @dragover="handleDragOver"
162
+ @dragleave="handleDragLeave"
163
+ @drop="handleDrop"
164
+ @click="triggerFileInput"
165
+ >
166
+ <input
167
+ id="audio-file-input"
168
+ type="file"
169
+ accept=".wav,.mp3,.flac,.m4a,audio/*"
170
+ hidden
171
+ @change="handleFileInput"
172
+ />
173
+
174
+ <div v-if="uploading" class="upload-loading">
175
+ <el-icon class="is-loading" :size="48"><UploadFilled /></el-icon>
176
+ <div class="upload-text">{{ t('common.loading') }}</div>
177
+ </div>
178
+
179
+ <div v-else class="upload-content">
180
+ <el-icon class="upload-icon" :size="48"><UploadFilled /></el-icon>
181
+ <div class="upload-text">{{ t('clone.dragOrClick') }}</div>
182
+ <div class="upload-hint">{{ t('clone.supportFormats') }}</div>
183
+
184
+ <!-- 预留:录制按钮 -->
185
+ <div v-if="showRecordButton" class="record-actions">
186
+ <el-button
187
+ type="primary"
188
+ :icon="Microphone"
189
+ circle
190
+ size="large"
191
+ @click.stop="startRecording"
192
+ />
193
+ <span class="record-hint">{{ t('clone.orRecord') }}</span>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- 已选择文件状态:显示文件信息 -->
199
+ <div v-else class="uploaded-file" :class="{ uploading: uploading, failed: uploadFailed }">
200
+ <div class="file-icon" :class="{ 'icon-failed': uploadFailed, 'icon-success': props.modelValue?.fileId }">
201
+ <el-icon v-if="uploading" class="is-loading" :size="32"><UploadFilled /></el-icon>
202
+ <el-icon v-else-if="uploadFailed" :size="32"><WarningFilled /></el-icon>
203
+ <el-icon v-else :size="32"><Document /></el-icon>
204
+ </div>
205
+ <div class="file-info">
206
+ <div class="file-name">{{ fileName }}</div>
207
+ <div class="file-meta">
208
+ <span v-if="uploading" class="uploading-text">{{ t('clone.uploading') }}</span>
209
+ <span v-else-if="uploadFailed" class="failed-text">{{ t('clone.uploadFailed') }}</span>
210
+ <template v-else>
211
+ <span v-if="fileSize">{{ fileSize }}</span>
212
+ <span v-if="fileDuration" class="duration">{{ fileDuration }}</span>
213
+ <span v-if="props.modelValue?.fileId" class="uploaded-badge">
214
+ <el-icon><CircleCheck /></el-icon>
215
+ {{ t('clone.uploaded') }}
216
+ </span>
217
+ </template>
218
+ </div>
219
+ </div>
220
+ <div class="file-actions">
221
+ <el-button
222
+ v-if="uploadFailed"
223
+ type="primary"
224
+ :icon="RefreshRight"
225
+ circle
226
+ size="small"
227
+ :disabled="disabled || uploading"
228
+ @click="retryUpload"
229
+ />
230
+ <el-button
231
+ type="danger"
232
+ :icon="Delete"
233
+ circle
234
+ size="small"
235
+ :disabled="disabled || uploading"
236
+ @click="clearFile"
237
+ />
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </template>
242
+
243
+ <style lang="scss" scoped>
244
+ .upload-zone-wrapper {
245
+ width: 100%;
246
+ }
247
+
248
+ .upload-zone {
249
+ display: flex;
250
+ flex-direction: column;
251
+ align-items: center;
252
+ justify-content: center;
253
+ min-height: 180px;
254
+ padding: 32px 24px;
255
+ border: 2px dashed var(--border-color);
256
+ border-radius: var(--border-radius-lg);
257
+ background: var(--bg-primary);
258
+ cursor: pointer;
259
+ transition: all var(--transition-fast);
260
+
261
+ &:hover:not(.disabled):not(.uploading) {
262
+ border-color: var(--primary-color);
263
+ background: var(--bg-secondary);
264
+ }
265
+
266
+ &.dragging {
267
+ border-color: var(--primary-color);
268
+ background: var(--bg-secondary);
269
+ border-style: solid;
270
+ }
271
+
272
+ &.disabled {
273
+ cursor: not-allowed;
274
+ opacity: 0.6;
275
+ }
276
+
277
+ &.uploading {
278
+ cursor: wait;
279
+ }
280
+ }
281
+
282
+ .upload-content,
283
+ .upload-loading {
284
+ display: flex;
285
+ flex-direction: column;
286
+ align-items: center;
287
+ text-align: center;
288
+ }
289
+
290
+ .upload-icon {
291
+ color: var(--text-muted);
292
+ margin-bottom: 12px;
293
+ }
294
+
295
+ .upload-text {
296
+ font-size: 15px;
297
+ color: var(--text-primary);
298
+ margin-bottom: 4px;
299
+ }
300
+
301
+ .upload-hint {
302
+ font-size: 12px;
303
+ color: var(--text-muted);
304
+ }
305
+
306
+ .record-actions {
307
+ display: flex;
308
+ align-items: center;
309
+ gap: 12px;
310
+ margin-top: 20px;
311
+ padding-top: 20px;
312
+ border-top: 1px solid var(--border-color-light);
313
+ }
314
+
315
+ .record-hint {
316
+ font-size: 13px;
317
+ color: var(--text-muted);
318
+ }
319
+
320
+ .uploaded-file {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 16px;
324
+ padding: 16px 20px;
325
+ background: var(--bg-secondary);
326
+ border-radius: var(--border-radius-lg);
327
+ border: 1px solid var(--border-color-light);
328
+ }
329
+
330
+ .file-icon {
331
+ display: flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ width: 56px;
335
+ height: 56px;
336
+ background: var(--primary-color);
337
+ border-radius: var(--border-radius);
338
+ color: white;
339
+ }
340
+
341
+ .file-info {
342
+ flex: 1;
343
+ min-width: 0;
344
+ }
345
+
346
+ .file-name {
347
+ font-size: 15px;
348
+ font-weight: 500;
349
+ color: var(--text-primary);
350
+ margin-bottom: 4px;
351
+ white-space: nowrap;
352
+ overflow: hidden;
353
+ text-overflow: ellipsis;
354
+ }
355
+
356
+ .file-meta {
357
+ display: flex;
358
+ align-items: center;
359
+ gap: 12px;
360
+ font-size: 13px;
361
+ color: var(--text-muted);
362
+
363
+ .duration {
364
+ color: var(--primary-color);
365
+ font-weight: 500;
366
+ }
367
+
368
+ .uploading-text {
369
+ color: var(--primary-color);
370
+ }
371
+
372
+ .failed-text {
373
+ color: var(--el-color-danger);
374
+ }
375
+
376
+ .uploaded-badge {
377
+ display: inline-flex;
378
+ align-items: center;
379
+ gap: 4px;
380
+ color: var(--success-color);
381
+ font-weight: 500;
382
+
383
+ .el-icon {
384
+ font-size: 14px;
385
+ }
386
+ }
387
+ }
388
+
389
+ .uploaded-file.failed {
390
+ border-color: var(--el-color-danger);
391
+ }
392
+
393
+ .file-icon.icon-failed {
394
+ background: var(--el-color-danger);
395
+ }
396
+
397
+ .file-icon.icon-success {
398
+ background: var(--success-color);
399
+ }
400
+
401
+ .file-actions {
402
+ flex-shrink: 0;
403
+ }
404
+ </style>
tts-voice-app/src/renderer/src/components/training/index.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 训练组件导出
3
+ */
4
+
5
+ export { default as TaskList } from './TaskList.vue'
6
+ export { default as TaskCard } from './TaskCard.vue'
7
+ export { default as TaskProgress } from './TaskProgress.vue'
8
+ export { default as CreateTaskDialog } from './CreateTaskDialog.vue'
9
+ export { default as UploadZone } from './UploadZone.vue'
10
+ export { default as TrainingLogs } from './TrainingLogs.vue'
tts-voice-app/src/renderer/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<{}, {}, any>
6
+ export default component
7
+ }
tts-voice-app/src/renderer/src/i18n/index.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createI18n } from 'vue-i18n'
2
+ import zhCN from '@/locales/zh-CN.json'
3
+ import enUS from '@/locales/en-US.json'
4
+
5
+ export type MessageSchema = typeof zhCN
6
+
7
+ const i18n = createI18n<[MessageSchema], 'zh-CN' | 'en-US'>({
8
+ legacy: false,
9
+ locale: localStorage.getItem('locale') || 'zh-CN',
10
+ fallbackLocale: 'en-US',
11
+ messages: {
12
+ 'zh-CN': zhCN,
13
+ 'en-US': enUS
14
+ }
15
+ })
16
+
17
+ export default i18n
tts-voice-app/src/renderer/src/locales/en-US.json ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nav": {
3
+ "textToSpeech": "Text to Speech",
4
+ "voiceLibrary": "Voice Library",
5
+ "voiceClone": "Voice Clone"
6
+ },
7
+ "tts": {
8
+ "title": "Text to Speech",
9
+ "placeholder": "Enter text to convert...",
10
+ "generate": "Generate",
11
+ "speed": "Speed",
12
+ "emotion": "Emotion",
13
+ "voice": "Voice",
14
+ "language": "Language",
15
+ "settings": "Settings",
16
+ "characters": "characters"
17
+ },
18
+ "library": {
19
+ "title": "Voice Library",
20
+ "import": "Import",
21
+ "search": "Search",
22
+ "preview": "Preview",
23
+ "edit": "Edit",
24
+ "export": "Export",
25
+ "delete": "Delete",
26
+ "empty": "No voices yet. Please train or import a voice first."
27
+ },
28
+ "clone": {
29
+ "title": "Voice Clone",
30
+ "quickMode": "Quick Mode",
31
+ "advancedMode": "Advanced Mode",
32
+ "uploadAudio": "Upload Audio",
33
+ "dragOrClick": "Drag files here or click to upload",
34
+ "supportFormats": "Supports wav, mp3, flac, m4a formats",
35
+ "uploading": "Uploading...",
36
+ "uploaded": "Uploaded",
37
+ "orRecord": "or record audio",
38
+ "voiceName": "Voice Name",
39
+ "voiceNamePlaceholder": "Enter voice name",
40
+ "language": "Language",
41
+ "quality": "Quality",
42
+ "qualityFast": "Fast",
43
+ "qualityStandard": "Standard",
44
+ "qualityHigh": "High Quality",
45
+ "minutes": " min",
46
+ "startTraining": "Start Training",
47
+ "cancelTraining": "Cancel Training",
48
+ "progress": "Training Progress",
49
+ "overallProgress": "Overall Progress",
50
+ "createTask": "Create Task",
51
+ "taskList": "Task List",
52
+ "taskCount": "{count} tasks",
53
+ "noTasks": "No training tasks yet",
54
+ "noTasksHint": "Click the button above to create your first training task",
55
+ "confirmCancel": "Are you sure you want to cancel this training task?",
56
+ "taskCreated": "Task created successfully",
57
+ "taskCancelled": "Task cancelled",
58
+ "createFailed": "Failed to create task",
59
+ "uploadFailed": "Failed to upload file",
60
+ "status": {
61
+ "queued": "Queued",
62
+ "running": "Running",
63
+ "completed": "Completed",
64
+ "failed": "Failed",
65
+ "cancelled": "Cancelled",
66
+ "interrupted": "Interrupted"
67
+ },
68
+ "stage": {
69
+ "audio_slice": "Audio Slice",
70
+ "asr": "Speech Recognition",
71
+ "text_feature": "Text Feature",
72
+ "hubert_feature": "HuBERT Feature",
73
+ "semantic_token": "Semantic Token",
74
+ "sovits_train": "SoVITS Training",
75
+ "gpt_train": "GPT Training"
76
+ },
77
+ "detail": {
78
+ "title": "Task Detail",
79
+ "back": "Back",
80
+ "logs": "Training Logs",
81
+ "noLogs": "No logs yet",
82
+ "stageProgress": "Training Stages",
83
+ "taskInfo": "Task Info",
84
+ "createdAt": "Created At",
85
+ "startedAt": "Started At",
86
+ "completedAt": "Completed At"
87
+ }
88
+ },
89
+ "common": {
90
+ "confirm": "Confirm",
91
+ "cancel": "Cancel",
92
+ "save": "Save",
93
+ "loading": "Loading...",
94
+ "success": "Success",
95
+ "error": "Error"
96
+ },
97
+ "theme": {
98
+ "light": "Light Mode",
99
+ "dark": "Dark Mode"
100
+ }
101
+ }
tts-voice-app/src/renderer/src/locales/zh-CN.json ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nav": {
3
+ "textToSpeech": "文本转语音",
4
+ "voiceLibrary": "音色库",
5
+ "voiceClone": "音色克隆"
6
+ },
7
+ "tts": {
8
+ "title": "文本转语音",
9
+ "placeholder": "输入要转换的文本...",
10
+ "generate": "生成",
11
+ "speed": "语速",
12
+ "emotion": "情绪",
13
+ "voice": "音色",
14
+ "language": "语言",
15
+ "settings": "设置",
16
+ "characters": "字符"
17
+ },
18
+ "library": {
19
+ "title": "音色库",
20
+ "import": "导入",
21
+ "search": "搜索",
22
+ "preview": "试听",
23
+ "edit": "编辑",
24
+ "export": "导出",
25
+ "delete": "删除",
26
+ "empty": "暂无音色,请先训练或导入音色"
27
+ },
28
+ "clone": {
29
+ "title": "音色克隆",
30
+ "quickMode": "快速模式",
31
+ "advancedMode": "高级模式",
32
+ "uploadAudio": "上传音频",
33
+ "dragOrClick": "拖拽文件到此处或点击上传",
34
+ "supportFormats": "支持 wav, mp3, flac, m4a 格式",
35
+ "uploading": "上传中...",
36
+ "uploaded": "已上传",
37
+ "orRecord": "或录制音频",
38
+ "voiceName": "音色名称",
39
+ "voiceNamePlaceholder": "请输入音色名称",
40
+ "language": "语言",
41
+ "quality": "质量",
42
+ "qualityFast": "快速",
43
+ "qualityStandard": "标准",
44
+ "qualityHigh": "高质量",
45
+ "minutes": "分钟",
46
+ "startTraining": "开始训练",
47
+ "cancelTraining": "取消训练",
48
+ "progress": "训练进度",
49
+ "overallProgress": "总体进度",
50
+ "createTask": "创建任务",
51
+ "taskList": "任务列表",
52
+ "taskCount": "共 {count} 个任务",
53
+ "noTasks": "暂无训练任务",
54
+ "noTasksHint": "点击上方按钮创建第一个训练任务",
55
+ "confirmCancel": "确定要取消这个训练任务吗?",
56
+ "taskCreated": "任务创建成功",
57
+ "taskCancelled": "任务已取消",
58
+ "createFailed": "创建任务失败",
59
+ "uploadFailed": "文件上传失败",
60
+ "status": {
61
+ "queued": "排队中",
62
+ "running": "训练中",
63
+ "completed": "已完成",
64
+ "failed": "失败",
65
+ "cancelled": "已取消",
66
+ "interrupted": "已中断"
67
+ },
68
+ "stage": {
69
+ "audio_slice": "音频切片",
70
+ "asr": "语音识别",
71
+ "text_feature": "文本特征",
72
+ "hubert_feature": "HuBERT特征",
73
+ "semantic_token": "语义Token",
74
+ "sovits_train": "SoVITS训练",
75
+ "gpt_train": "GPT训练"
76
+ },
77
+ "detail": {
78
+ "title": "任务详情",
79
+ "back": "返回",
80
+ "logs": "训练日志",
81
+ "noLogs": "暂无日志",
82
+ "stageProgress": "训练阶段",
83
+ "taskInfo": "任务信息",
84
+ "createdAt": "创建时间",
85
+ "startedAt": "开始时间",
86
+ "completedAt": "完成时间"
87
+ }
88
+ },
89
+ "common": {
90
+ "confirm": "确认",
91
+ "cancel": "取消",
92
+ "save": "保存",
93
+ "loading": "加载中...",
94
+ "success": "成功",
95
+ "error": "错误"
96
+ },
97
+ "theme": {
98
+ "light": "浅色模式",
99
+ "dark": "深色模式"
100
+ }
101
+ }
tts-voice-app/src/renderer/src/main.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import ElementPlus from 'element-plus'
4
+ import 'element-plus/dist/index.css'
5
+
6
+ import App from './App.vue'
7
+ import router from './router'
8
+ import i18n from './i18n'
9
+
10
+ import './styles/global.scss'
11
+
12
+ const app = createApp(App)
13
+
14
+ app.use(createPinia())
15
+ app.use(router)
16
+ app.use(i18n)
17
+ app.use(ElementPlus)
18
+
19
+ app.mount('#app')
tts-voice-app/src/renderer/src/router/index.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRouter, createWebHashHistory } from 'vue-router'
2
+ import type { RouteRecordRaw } from 'vue-router'
3
+
4
+ const routes: RouteRecordRaw[] = [
5
+ {
6
+ path: '/',
7
+ redirect: '/tts'
8
+ },
9
+ {
10
+ path: '/tts',
11
+ name: 'TextToSpeech',
12
+ component: () => import('@/views/TextToSpeech.vue'),
13
+ meta: { title: 'Text to Speech', icon: 'text' }
14
+ },
15
+ {
16
+ path: '/library',
17
+ name: 'VoiceLibrary',
18
+ component: () => import('@/views/VoiceLibrary.vue'),
19
+ meta: { title: 'Voice Library', icon: 'mic' }
20
+ },
21
+ {
22
+ path: '/clone',
23
+ name: 'VoiceClone',
24
+ component: () => import('@/views/VoiceClone.vue'),
25
+ meta: { title: 'Voice Clone', icon: 'copy' }
26
+ },
27
+ {
28
+ path: '/clone/:taskId',
29
+ name: 'TaskDetail',
30
+ component: () => import('@/views/TaskDetail.vue'),
31
+ meta: { title: 'Task Detail', icon: 'copy' }
32
+ }
33
+ ]
34
+
35
+ const router = createRouter({
36
+ history: createWebHashHistory(),
37
+ routes
38
+ })
39
+
40
+ export default router
tts-voice-app/src/renderer/src/stores/app.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from 'pinia'
2
+
3
+ export type Theme = 'light' | 'dark'
4
+ export type Locale = 'zh-CN' | 'en-US'
5
+
6
+ interface AppState {
7
+ theme: Theme
8
+ locale: Locale
9
+ }
10
+
11
+ export const useAppStore = defineStore('app', {
12
+ state: (): AppState => ({
13
+ theme: (localStorage.getItem('theme') as Theme) || 'light',
14
+ locale: (localStorage.getItem('locale') as Locale) || 'zh-CN'
15
+ }),
16
+
17
+ actions: {
18
+ setTheme(theme: Theme) {
19
+ this.theme = theme
20
+ localStorage.setItem('theme', theme)
21
+ document.documentElement.setAttribute('data-theme', theme)
22
+ },
23
+
24
+ toggleTheme() {
25
+ this.setTheme(this.theme === 'light' ? 'dark' : 'light')
26
+ },
27
+
28
+ setLocale(locale: Locale) {
29
+ this.locale = locale
30
+ localStorage.setItem('locale', locale)
31
+ }
32
+ }
33
+ })
tts-voice-app/src/renderer/src/stores/training.ts ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 训练任务状态管理
3
+ *
4
+ * 管理任务列表、分页、进度订阅
5
+ */
6
+
7
+ import { defineStore } from 'pinia'
8
+ import { trainingApi } from '../api/training'
9
+ import type {
10
+ TaskResponse,
11
+ QuickModeRequest,
12
+ ProgressEvent,
13
+ TaskStatus,
14
+ AudioSource,
15
+ LogEntry
16
+ } from '../types'
17
+
18
+ interface TrainingState {
19
+ // 任务列表
20
+ tasks: TaskResponse[]
21
+ total: number
22
+
23
+ // 分页
24
+ currentPage: number
25
+ pageSize: number
26
+
27
+ // 加载状态
28
+ loading: boolean
29
+ creating: boolean
30
+ uploading: boolean
31
+
32
+ // 当前活跃的任务
33
+ activeTaskId: string | null
34
+
35
+ // 进度映射 taskId -> ProgressEvent
36
+ progressMap: Record<string, ProgressEvent>
37
+
38
+ // 日志映射 taskId -> LogEntry[]
39
+ logsMap: Record<string, LogEntry[]>
40
+
41
+ // SSE 连接映射 taskId -> EventSource
42
+ eventSourceMap: Record<string, EventSource>
43
+
44
+ // 上传的音频文件
45
+ uploadedAudio: AudioSource | null
46
+ }
47
+
48
+ export const useTrainingStore = defineStore('training', {
49
+ state: (): TrainingState => ({
50
+ tasks: [],
51
+ total: 0,
52
+ currentPage: 1,
53
+ pageSize: 10,
54
+ loading: false,
55
+ creating: false,
56
+ uploading: false,
57
+ activeTaskId: null,
58
+ progressMap: {},
59
+ logsMap: {},
60
+ eventSourceMap: {},
61
+ uploadedAudio: null
62
+ }),
63
+
64
+ getters: {
65
+ /**
66
+ * 计算总页数
67
+ */
68
+ totalPages(): number {
69
+ return Math.ceil(this.total / this.pageSize)
70
+ },
71
+
72
+ /**
73
+ * 获取当前活跃任务
74
+ */
75
+ activeTask(): TaskResponse | null {
76
+ if (!this.activeTaskId) return null
77
+ return this.tasks.find((t) => t.id === this.activeTaskId) || null
78
+ },
79
+
80
+ /**
81
+ * 获取正在运行的任务
82
+ */
83
+ runningTasks(): TaskResponse[] {
84
+ return this.tasks.filter((t) => t.status === 'running' || t.status === 'queued')
85
+ },
86
+
87
+ /**
88
+ * 是否有正在运行的任务
89
+ */
90
+ hasRunningTask(): boolean {
91
+ return this.runningTasks.length > 0
92
+ }
93
+ },
94
+
95
+ actions: {
96
+ /**
97
+ * 获取任务列表
98
+ */
99
+ async fetchTasks(status?: TaskStatus) {
100
+ this.loading = true
101
+ try {
102
+ const offset = (this.currentPage - 1) * this.pageSize
103
+ const response = await trainingApi.listTasks({
104
+ status,
105
+ limit: this.pageSize,
106
+ offset
107
+ })
108
+
109
+ this.tasks = response.items
110
+ this.total = response.total
111
+ // 不在列表页面订阅 SSE,只有打开详情时才订阅
112
+ } catch (error) {
113
+ console.error('Failed to fetch tasks:', error)
114
+ throw error
115
+ } finally {
116
+ this.loading = false
117
+ }
118
+ },
119
+
120
+ /**
121
+ * 切换页码
122
+ */
123
+ async changePage(page: number) {
124
+ this.currentPage = page
125
+ await this.fetchTasks()
126
+ },
127
+
128
+ /**
129
+ * 上传音频文件
130
+ */
131
+ async uploadAudio(file: File): Promise<string> {
132
+ this.uploading = true
133
+ try {
134
+ const response = await trainingApi.uploadAudio(file)
135
+ // 后端返回格式: { success, message, file: FileMetadata }
136
+ // FileMetadata: { id, filename, duration_seconds, size_bytes, ... }
137
+ const fileMetadata = response.file
138
+ this.uploadedAudio = {
139
+ type: 'file',
140
+ file,
141
+ fileId: fileMetadata.id,
142
+ filename: fileMetadata.filename,
143
+ duration: fileMetadata.duration_seconds ?? undefined
144
+ }
145
+ return fileMetadata.id
146
+ } catch (error) {
147
+ console.error('Failed to upload audio:', error)
148
+ throw error
149
+ } finally {
150
+ this.uploading = false
151
+ }
152
+ },
153
+
154
+ /**
155
+ * 清除上传的音频
156
+ */
157
+ clearUploadedAudio() {
158
+ this.uploadedAudio = null
159
+ },
160
+
161
+ /**
162
+ * 创建训练任务
163
+ */
164
+ async createTask(request: QuickModeRequest): Promise<TaskResponse> {
165
+ this.creating = true
166
+ try {
167
+ const task = await trainingApi.createTask(request)
168
+
169
+ // 添加到列表开头
170
+ this.tasks.unshift(task)
171
+ this.total += 1
172
+
173
+ // 不在创建时订阅进度,只在用户打开详情时订阅
174
+
175
+ // 清除上传的音频
176
+ this.clearUploadedAudio()
177
+
178
+ return task
179
+ } catch (error) {
180
+ console.error('Failed to create task:', error)
181
+ throw error
182
+ } finally {
183
+ this.creating = false
184
+ }
185
+ },
186
+
187
+ /**
188
+ * 取消任务
189
+ */
190
+ async cancelTask(taskId: string): Promise<boolean> {
191
+ try {
192
+ await trainingApi.cancelTask(taskId)
193
+
194
+ // 取消进度订阅
195
+ this.unsubscribeProgress(taskId)
196
+
197
+ // 更新任务状态
198
+ const task = this.tasks.find((t) => t.id === taskId)
199
+ if (task) {
200
+ task.status = 'cancelled'
201
+ }
202
+
203
+ return true
204
+ } catch (error) {
205
+ console.error('Failed to cancel task:', error)
206
+ throw error
207
+ }
208
+ },
209
+
210
+ /**
211
+ * 订阅任务进度
212
+ */
213
+ subscribeProgress(taskId: string) {
214
+ // 避免重复订阅
215
+ if (this.eventSourceMap[taskId]) {
216
+ return
217
+ }
218
+
219
+ try {
220
+ const eventSource = trainingApi.subscribeProgress(taskId)
221
+
222
+ // 进度更新事件
223
+ eventSource.addEventListener('progress', (e: MessageEvent) => {
224
+ try {
225
+ const data = JSON.parse(e.data) as ProgressEvent
226
+ this.handleProgressUpdate(taskId, data)
227
+ } catch (err) {
228
+ console.error('Failed to parse progress event:', err)
229
+ }
230
+ })
231
+
232
+ // 完成事件
233
+ eventSource.addEventListener('completed', (e: MessageEvent) => {
234
+ try {
235
+ const data = JSON.parse(e.data) as ProgressEvent
236
+ this.handleProgressUpdate(taskId, { ...data, status: 'completed' })
237
+ } catch (err) {
238
+ console.error('Failed to parse completed event:', err)
239
+ }
240
+ this.unsubscribeProgress(taskId)
241
+ // 更新本地任务状态,不再重新请求列表
242
+ const task = this.tasks.find((t) => t.id === taskId)
243
+ if (task) {
244
+ task.status = 'completed'
245
+ task.overall_progress = 1.0
246
+ task.progress = 1.0
247
+ }
248
+ })
249
+
250
+ // 失败事件
251
+ eventSource.addEventListener('failed', (e: MessageEvent) => {
252
+ try {
253
+ const data = JSON.parse(e.data) as ProgressEvent
254
+ this.handleProgressUpdate(taskId, { ...data, status: 'failed' })
255
+ } catch (err) {
256
+ console.error('Failed to parse failed event:', err)
257
+ }
258
+ this.unsubscribeProgress(taskId)
259
+ // 更新本地任务状态,不再重新请求列表
260
+ const task = this.tasks.find((t) => t.id === taskId)
261
+ if (task) {
262
+ task.status = 'failed'
263
+ }
264
+ })
265
+
266
+ // 取消事件
267
+ eventSource.addEventListener('cancelled', () => {
268
+ this.unsubscribeProgress(taskId)
269
+ // 更新本地任务状态,不再重新请求列表
270
+ const task = this.tasks.find((t) => t.id === taskId)
271
+ if (task) {
272
+ task.status = 'cancelled'
273
+ }
274
+ })
275
+
276
+ // 日志事件
277
+ eventSource.addEventListener('log', (e: MessageEvent) => {
278
+ try {
279
+ const data = JSON.parse(e.data)
280
+ this.handleLogEvent(taskId, data)
281
+ } catch (err) {
282
+ console.error('Failed to parse log event:', err)
283
+ }
284
+ })
285
+
286
+ // 心跳事件(忽略)
287
+ eventSource.addEventListener('heartbeat', () => {
288
+ // 保持连接
289
+ })
290
+
291
+ // 错误处理 - 只关闭连接,不重新请求
292
+ eventSource.onerror = () => {
293
+ console.warn('SSE connection error for task:', taskId)
294
+ this.unsubscribeProgress(taskId)
295
+ // 不调用 fetchTasks,避免循环请求
296
+ }
297
+
298
+ this.eventSourceMap[taskId] = eventSource
299
+ } catch (error) {
300
+ console.error('Failed to subscribe progress:', error)
301
+ }
302
+ },
303
+
304
+ /**
305
+ * 取消进度订阅
306
+ */
307
+ unsubscribeProgress(taskId: string) {
308
+ const eventSource = this.eventSourceMap[taskId]
309
+ if (eventSource) {
310
+ eventSource.close()
311
+ delete this.eventSourceMap[taskId]
312
+ }
313
+ delete this.progressMap[taskId]
314
+ // 注意:不删除 logsMap,保留日志历史
315
+ },
316
+
317
+ /**
318
+ * 处理日志事件
319
+ */
320
+ handleLogEvent(taskId: string, data: { level?: string; message?: string; stage?: string }) {
321
+ if (!this.logsMap[taskId]) {
322
+ this.logsMap[taskId] = []
323
+ }
324
+
325
+ const logEntry: LogEntry = {
326
+ level: (data.level as LogEntry['level']) || 'info',
327
+ message: data.message || '',
328
+ timestamp: new Date().toISOString(),
329
+ stage: data.stage
330
+ }
331
+
332
+ this.logsMap[taskId].push(logEntry)
333
+
334
+ // 限制日志数量,防止内存溢出
335
+ const MAX_LOGS = 1000
336
+ if (this.logsMap[taskId].length > MAX_LOGS) {
337
+ this.logsMap[taskId] = this.logsMap[taskId].slice(-MAX_LOGS)
338
+ }
339
+ },
340
+
341
+ /**
342
+ * 清除任务日志
343
+ */
344
+ clearLogs(taskId: string) {
345
+ delete this.logsMap[taskId]
346
+ },
347
+
348
+ /**
349
+ * 获取任务日志
350
+ */
351
+ getLogs(taskId: string): LogEntry[] {
352
+ return this.logsMap[taskId] || []
353
+ },
354
+
355
+ /**
356
+ * 处理进度更新
357
+ */
358
+ handleProgressUpdate(taskId: string, data: ProgressEvent) {
359
+ // 更新进度映射
360
+ this.progressMap[taskId] = data
361
+
362
+ // 更新任务列表中的状态
363
+ const task = this.tasks.find((t) => t.id === taskId)
364
+ if (task) {
365
+ if (data.status) {
366
+ task.status = data.status
367
+ }
368
+ if (data.stage) {
369
+ task.current_stage = data.stage
370
+ }
371
+ if (typeof data.progress === 'number') {
372
+ task.progress = data.progress
373
+ }
374
+ if (typeof data.overall_progress === 'number') {
375
+ task.overall_progress = data.overall_progress
376
+ }
377
+ if (data.message) {
378
+ task.message = data.message
379
+ }
380
+ if (data.error) {
381
+ task.error_message = data.error
382
+ }
383
+ }
384
+ },
385
+
386
+ /**
387
+ * 刷新任务列表
388
+ */
389
+ async refresh() {
390
+ await this.fetchTasks()
391
+ },
392
+
393
+ /**
394
+ * 清理所有 SSE 连接
395
+ */
396
+ cleanup() {
397
+ for (const taskId of Object.keys(this.eventSourceMap)) {
398
+ this.unsubscribeProgress(taskId)
399
+ }
400
+ },
401
+
402
+ /**
403
+ * 设置活跃任务
404
+ */
405
+ setActiveTask(taskId: string | null) {
406
+ this.activeTaskId = taskId
407
+ },
408
+
409
+ /**
410
+ * 根据 ID 获取任务
411
+ * 优先从本地列表获取,否则调用 API
412
+ */
413
+ async getTaskById(taskId: string): Promise<TaskResponse | null> {
414
+ // 先从本地查找
415
+ const localTask = this.tasks.find((t) => t.id === taskId)
416
+ if (localTask) {
417
+ return localTask
418
+ }
419
+
420
+ // 调用 API 获取
421
+ try {
422
+ const task = await trainingApi.getTask(taskId)
423
+ return task
424
+ } catch (error) {
425
+ console.error('Failed to get task:', error)
426
+ return null
427
+ }
428
+ }
429
+ }
430
+ })
tts-voice-app/src/renderer/src/styles/global.scss ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @use './variables.scss';
2
+ @use './reset.scss';
3
+
4
+ // 全局样式
5
+ html,
6
+ body,
7
+ #app {
8
+ width: 100%;
9
+ height: 100%;
10
+ background-color: var(--bg-primary);
11
+ color: var(--text-primary);
12
+ transition: background-color var(--transition-normal), color var(--transition-normal);
13
+ }
14
+
15
+ // Element Plus 主题覆盖
16
+ .el-button--primary {
17
+ --el-button-bg-color: var(--primary-color);
18
+ --el-button-border-color: var(--primary-color);
19
+ --el-button-hover-bg-color: var(--primary-color-light);
20
+ --el-button-hover-border-color: var(--primary-color-light);
21
+ --el-button-active-bg-color: var(--primary-color-dark);
22
+ --el-button-active-border-color: var(--primary-color-dark);
23
+ }
24
+
25
+ // 通用类
26
+ .page-container {
27
+ max-width: 1200px;
28
+ margin: 0 auto;
29
+ }
30
+
31
+ .page-title {
32
+ font-size: 24px;
33
+ font-weight: 600;
34
+ margin-bottom: 24px;
35
+ color: var(--text-primary);
36
+ }
37
+
38
+ .card {
39
+ background-color: var(--bg-secondary);
40
+ border-radius: var(--border-radius-lg);
41
+ padding: 20px;
42
+ box-shadow: 0 2px 8px var(--shadow-color);
43
+ }
tts-voice-app/src/renderer/src/styles/reset.scss ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CSS Reset
2
+ *,
3
+ *::before,
4
+ *::after {
5
+ box-sizing: border-box;
6
+ margin: 0;
7
+ padding: 0;
8
+ }
9
+
10
+ html {
11
+ font-size: 16px;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ body {
17
+ font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ line-height: 1.5;
19
+ overflow: hidden;
20
+ }
21
+
22
+ a {
23
+ color: inherit;
24
+ text-decoration: none;
25
+ }
26
+
27
+ button {
28
+ border: none;
29
+ background: none;
30
+ cursor: pointer;
31
+ font-family: inherit;
32
+ }
33
+
34
+ input,
35
+ textarea {
36
+ font-family: inherit;
37
+ font-size: inherit;
38
+ }
39
+
40
+ ul,
41
+ ol {
42
+ list-style: none;
43
+ }
44
+
45
+ img {
46
+ max-width: 100%;
47
+ height: auto;
48
+ }
49
+
50
+ // 滚动条样式
51
+ ::-webkit-scrollbar {
52
+ width: 6px;
53
+ height: 6px;
54
+ }
55
+
56
+ ::-webkit-scrollbar-track {
57
+ background: transparent;
58
+ }
59
+
60
+ ::-webkit-scrollbar-thumb {
61
+ background: var(--border-color);
62
+ border-radius: 3px;
63
+
64
+ &:hover {
65
+ background: var(--text-muted);
66
+ }
67
+ }
tts-voice-app/src/renderer/src/styles/variables.scss ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CSS 变量 - 主题相关
2
+ :root {
3
+ // 默认浅色主题
4
+ --bg-primary: #ffffff;
5
+ --bg-secondary: #f5f7fa;
6
+ --bg-sidebar: #1a1a2e;
7
+ --text-primary: #303133;
8
+ --text-secondary: #606266;
9
+ --text-muted: #909399;
10
+ --border-color: #dcdfe6;
11
+ --border-color-light: #e4e7ed;
12
+ --shadow-color: rgba(0, 0, 0, 0.1);
13
+
14
+ // 品牌色
15
+ --primary-color: #6366f1;
16
+ --primary-color-light: #818cf8;
17
+ --primary-color-dark: #4f46e5;
18
+
19
+ // 功能色
20
+ --success-color: #10b981;
21
+ --warning-color: #f59e0b;
22
+ --danger-color: #ef4444;
23
+ --info-color: #3b82f6;
24
+
25
+ // 尺寸
26
+ --sidebar-width: 240px;
27
+ --header-height: 60px;
28
+ --border-radius: 8px;
29
+ --border-radius-lg: 12px;
30
+
31
+ // 过渡
32
+ --transition-fast: 0.15s ease;
33
+ --transition-normal: 0.3s ease;
34
+ }
35
+
36
+ // 深色主题
37
+ :root[data-theme="dark"] {
38
+ --bg-primary: #1a1a1a;
39
+ --bg-secondary: #252525;
40
+ --bg-sidebar: #0f0f1a;
41
+ --text-primary: #e5eaf3;
42
+ --text-secondary: #a3a6ad;
43
+ --text-muted: #73767a;
44
+ --border-color: #4c4d4f;
45
+ --border-color-light: #3a3a3c;
46
+ --shadow-color: rgba(0, 0, 0, 0.3);
47
+ }
tts-voice-app/src/renderer/src/types/index.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // API 响应类型
2
+ export interface ApiResponse<T = unknown> {
3
+ code: number
4
+ data: T
5
+ message: string
6
+ }
7
+
8
+ // 音色类型
9
+ export interface Voice {
10
+ id: string
11
+ name: string
12
+ language: string
13
+ version: string
14
+ created_at: string
15
+ sovits_path: string
16
+ gpt_path: string
17
+ }
18
+
19
+ // TTS 请求类型
20
+ export interface TTSRequest {
21
+ text: string
22
+ voice_id: string
23
+ speed?: number
24
+ emotion?: string
25
+ }
26
+
27
+ // TTS 响应类型
28
+ export interface TTSResponse {
29
+ audio_url: string
30
+ duration: number
31
+ }
32
+
33
+ // ============================================================
34
+ // 训练任务相关类型(与后端 schema 对齐)
35
+ // ============================================================
36
+
37
+ /**
38
+ * 任务状态
39
+ */
40
+ export type TaskStatus =
41
+ | 'queued'
42
+ | 'running'
43
+ | 'completed'
44
+ | 'failed'
45
+ | 'cancelled'
46
+ | 'interrupted'
47
+
48
+ /**
49
+ * 训练质量预设
50
+ */
51
+ export type TrainingQuality = 'fast' | 'standard' | 'high'
52
+
53
+ /**
54
+ * 模型版本
55
+ */
56
+ export type ModelVersion = 'v1' | 'v2' | 'v2Pro' | 'v3' | 'v4'
57
+
58
+ /**
59
+ * Quick Mode 训练选项
60
+ */
61
+ export interface QuickModeOptions {
62
+ version?: ModelVersion
63
+ language?: string
64
+ quality?: TrainingQuality
65
+ }
66
+
67
+ /**
68
+ * Quick Mode 创建任务请求
69
+ */
70
+ export interface QuickModeRequest {
71
+ exp_name: string
72
+ audio_file_id: string
73
+ options?: QuickModeOptions
74
+ }
75
+
76
+ /**
77
+ * 任务响应(Quick Mode)
78
+ */
79
+ export interface TaskResponse {
80
+ id: string
81
+ exp_name: string
82
+ status: TaskStatus
83
+ current_stage?: string | null
84
+ progress: number
85
+ overall_progress: number
86
+ message?: string | null
87
+ error_message?: string | null
88
+ created_at?: string | null
89
+ started_at?: string | null
90
+ completed_at?: string | null
91
+ }
92
+
93
+ /**
94
+ * 任务列表响应
95
+ */
96
+ export interface TaskListResponse {
97
+ items: TaskResponse[]
98
+ total: number
99
+ limit: number
100
+ offset: number
101
+ }
102
+
103
+ /**
104
+ * 进度更新事件数据
105
+ */
106
+ export interface ProgressEvent {
107
+ type?: 'progress' | 'log' | 'heartbeat' | 'completed' | 'failed' | 'cancelled'
108
+ status?: TaskStatus
109
+ stage?: string
110
+ progress?: number
111
+ overall_progress?: number
112
+ message?: string
113
+ error?: string
114
+ timestamp?: string
115
+ }
116
+
117
+ // ============================================================
118
+ // 文件上传相关类型
119
+ // ============================================================
120
+
121
+ /**
122
+ * 文件用途类型
123
+ */
124
+ export type FilePurpose = 'training' | 'reference' | 'output'
125
+
126
+ /**
127
+ * 文件元数据
128
+ *
129
+ * 对应后端 FileMetadata schema
130
+ */
131
+ export interface FileMetadata {
132
+ id: string
133
+ filename: string
134
+ content_type?: string | null
135
+ size_bytes: number
136
+ purpose?: FilePurpose | null
137
+ duration_seconds?: number | null
138
+ sample_rate?: number | null
139
+ uploaded_at: string
140
+ }
141
+
142
+ /**
143
+ * 文件上传响应
144
+ *
145
+ * 对应后端 FileUploadResponse schema
146
+ */
147
+ export interface FileUploadResponse {
148
+ success: boolean
149
+ message: string
150
+ file: FileMetadata
151
+ }
152
+
153
+ /**
154
+ * 文件列表响应
155
+ */
156
+ export interface FileListResponse {
157
+ items: FileMetadata[]
158
+ total: number
159
+ limit: number
160
+ offset: number
161
+ }
162
+
163
+ /**
164
+ * 文件删除响应
165
+ */
166
+ export interface FileDeleteResponse {
167
+ success: boolean
168
+ message: string
169
+ file_id: string
170
+ }
171
+
172
+ /**
173
+ * 通用成功响应
174
+ *
175
+ * 对应后端 SuccessResponse schema
176
+ */
177
+ export interface SuccessResponse {
178
+ success: boolean
179
+ message: string
180
+ }
181
+
182
+ /**
183
+ * 错误响应
184
+ *
185
+ * 对应后端 ErrorResponse schema
186
+ */
187
+ export interface ErrorResponse {
188
+ success: boolean
189
+ message: string
190
+ code?: string | null
191
+ details?: unknown
192
+ }
193
+
194
+ // ============================================================
195
+ // 音频来源类型(用于 UploadZone 组件)
196
+ // ============================================================
197
+
198
+ /**
199
+ * 音频来源类型
200
+ */
201
+ export type AudioSourceType = 'file' | 'recording'
202
+
203
+ /**
204
+ * 音频来源
205
+ */
206
+ export interface AudioSource {
207
+ type: AudioSourceType
208
+ file?: File
209
+ fileId?: string
210
+ filename?: string
211
+ duration?: number
212
+ }
213
+
214
+ // ============================================================
215
+ // 训练阶段类型
216
+ // ============================================================
217
+
218
+ /**
219
+ * 训练阶段类型
220
+ */
221
+ export type StageType =
222
+ | 'audio_slice'
223
+ | 'asr'
224
+ | 'text_feature'
225
+ | 'hubert_feature'
226
+ | 'semantic_token'
227
+ | 'sovits_train'
228
+ | 'gpt_train'
229
+
230
+ /**
231
+ * 训练阶段信息
232
+ */
233
+ export interface TrainingStage {
234
+ type: StageType
235
+ name: string
236
+ label: string
237
+ status: 'pending' | 'running' | 'completed' | 'failed'
238
+ progress: number
239
+ }
240
+
241
+ /**
242
+ * 所有训练阶段配置
243
+ */
244
+ export const TRAINING_STAGES: { type: StageType; name: string }[] = [
245
+ { type: 'audio_slice', name: '音频切片' },
246
+ { type: 'asr', name: '语音识别' },
247
+ { type: 'text_feature', name: '文本特征' },
248
+ { type: 'hubert_feature', name: 'HuBERT特征' },
249
+ { type: 'semantic_token', name: '语义Token' },
250
+ { type: 'sovits_train', name: 'SoVITS训练' },
251
+ { type: 'gpt_train', name: 'GPT训练' }
252
+ ]
253
+
254
+ // 保留旧的 TrainingTask 类型作为别名,���容现有代码
255
+ export type TrainingTask = TaskResponse
256
+
257
+ // ============================================================
258
+ // 日志相关类型
259
+ // ============================================================
260
+
261
+ /**
262
+ * 日志级别
263
+ */
264
+ export type LogLevel = 'info' | 'warning' | 'error'
265
+
266
+ /**
267
+ * 日志条目
268
+ */
269
+ export interface LogEntry {
270
+ level: LogLevel
271
+ message: string
272
+ timestamp: string
273
+ stage?: string
274
+ }
tts-voice-app/src/renderer/src/views/TaskDetail.vue ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 任务详情页面
4
+ *
5
+ * 展示训练任务的详细进度和实时日志
6
+ */
7
+
8
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
9
+ import { useRoute, useRouter } from 'vue-router'
10
+ import { useI18n } from 'vue-i18n'
11
+ import { ArrowLeft, Delete, Loading } from '@element-plus/icons-vue'
12
+ import { ElMessage, ElMessageBox } from 'element-plus'
13
+ import TaskProgress from '../components/training/TaskProgress.vue'
14
+ import TrainingLogs from '../components/training/TrainingLogs.vue'
15
+ import { useTrainingStore } from '../stores/training'
16
+ import type { TaskResponse } from '../types'
17
+
18
+ const { t } = useI18n()
19
+ const route = useRoute()
20
+ const router = useRouter()
21
+ const store = useTrainingStore()
22
+
23
+ // 状态
24
+ const task = ref<TaskResponse | null>(null)
25
+ const loading = ref(true)
26
+ const error = ref<string | null>(null)
27
+
28
+ // 获取路由参数
29
+ const taskId = computed(() => route.params.taskId as string)
30
+
31
+ // 计算属性
32
+ const progress = computed(() => store.progressMap[taskId.value])
33
+ const logs = computed(() => store.getLogs(taskId.value))
34
+ const isRunning = computed(() => task.value?.status === 'running' || task.value?.status === 'queued')
35
+ const canCancel = computed(() => task.value?.status === 'running' || task.value?.status === 'queued')
36
+
37
+ // 格式化时间
38
+ function formatTime(dateStr?: string | null) {
39
+ if (!dateStr) return '-'
40
+ const date = new Date(dateStr)
41
+ return date.toLocaleString()
42
+ }
43
+
44
+ // 获取状态标签类型
45
+ function getStatusType(status?: string) {
46
+ switch (status) {
47
+ case 'completed':
48
+ return 'success'
49
+ case 'failed':
50
+ return 'danger'
51
+ case 'cancelled':
52
+ case 'interrupted':
53
+ return 'warning'
54
+ case 'running':
55
+ case 'queued':
56
+ return 'primary'
57
+ default:
58
+ return 'info'
59
+ }
60
+ }
61
+
62
+ // 返回列表
63
+ function handleBack() {
64
+ router.push('/clone')
65
+ }
66
+
67
+ // 取消任务
68
+ async function handleCancel() {
69
+ if (!task.value) return
70
+
71
+ try {
72
+ await ElMessageBox.confirm(t('clone.confirmCancel'), t('common.confirm'), {
73
+ confirmButtonText: t('common.confirm'),
74
+ cancelButtonText: t('common.cancel'),
75
+ type: 'warning'
76
+ })
77
+
78
+ await store.cancelTask(task.value.id)
79
+ ElMessage.success(t('clone.taskCancelled'))
80
+
81
+ // 刷新任务状态
82
+ const updatedTask = await store.getTaskById(taskId.value)
83
+ if (updatedTask) {
84
+ task.value = updatedTask
85
+ }
86
+ } catch {
87
+ // 用户取消操作或取消失败
88
+ }
89
+ }
90
+
91
+ // 加载任务详情
92
+ async function loadTask() {
93
+ loading.value = true
94
+ error.value = null
95
+
96
+ try {
97
+ const result = await store.getTaskById(taskId.value)
98
+ if (result) {
99
+ task.value = result
100
+ } else {
101
+ error.value = '任务不存在'
102
+ }
103
+ } catch (e) {
104
+ error.value = (e as Error).message || '加载失败'
105
+ } finally {
106
+ loading.value = false
107
+ }
108
+ }
109
+
110
+ // 监听任务状态变化(从 store 更新)
111
+ watch(
112
+ () => store.tasks.find((t) => t.id === taskId.value),
113
+ (updatedTask) => {
114
+ if (updatedTask && task.value) {
115
+ task.value = { ...updatedTask }
116
+ }
117
+ },
118
+ { deep: true }
119
+ )
120
+
121
+ // 监听 SSE 进度更新,同步到 task
122
+ watch(
123
+ progress,
124
+ (newProgress) => {
125
+ if (newProgress && task.value) {
126
+ // 同步进度数据到 task
127
+ if (newProgress.stage) {
128
+ task.value.current_stage = newProgress.stage
129
+ }
130
+ if (typeof newProgress.progress === 'number') {
131
+ task.value.progress = newProgress.progress
132
+ }
133
+ if (typeof newProgress.overall_progress === 'number') {
134
+ task.value.overall_progress = newProgress.overall_progress
135
+ }
136
+ if (newProgress.message) {
137
+ task.value.message = newProgress.message
138
+ }
139
+ if (newProgress.status) {
140
+ task.value.status = newProgress.status
141
+ }
142
+ }
143
+ },
144
+ { deep: true }
145
+ )
146
+
147
+ // 生命周期
148
+ onMounted(async () => {
149
+ await loadTask()
150
+
151
+ // 如果任务正在运行,订阅进度
152
+ if (task.value && isRunning.value) {
153
+ store.subscribeProgress(taskId.value)
154
+ }
155
+ })
156
+
157
+ onUnmounted(() => {
158
+ // 取消订阅
159
+ store.unsubscribeProgress(taskId.value)
160
+ })
161
+
162
+ // 当任务状态变为运行时订阅进度
163
+ watch(isRunning, (running) => {
164
+ if (running) {
165
+ store.subscribeProgress(taskId.value)
166
+ }
167
+ })
168
+ </script>
169
+
170
+ <template>
171
+ <div class="task-detail-page">
172
+ <!-- 页面头部 -->
173
+ <div class="detail-header">
174
+ <div class="header-left">
175
+ <el-button :icon="ArrowLeft" text @click="handleBack">
176
+ {{ t('clone.detail.back') }}
177
+ </el-button>
178
+ <h1 class="page-title">{{ task?.exp_name || t('clone.detail.title') }}</h1>
179
+ <el-tag v-if="task" :type="getStatusType(task.status)" size="default">
180
+ {{ t(`clone.status.${task.status}`) }}
181
+ </el-tag>
182
+ </div>
183
+ <div class="header-right">
184
+ <el-button
185
+ v-if="canCancel"
186
+ type="danger"
187
+ :icon="Delete"
188
+ @click="handleCancel"
189
+ >
190
+ {{ t('clone.cancelTraining') }}
191
+ </el-button>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- 加载状态 -->
196
+ <div v-if="loading" class="loading-container">
197
+ <el-icon class="is-loading" :size="32">
198
+ <Loading />
199
+ </el-icon>
200
+ <span>{{ t('common.loading') }}</span>
201
+ </div>
202
+
203
+ <!-- 错误状态 -->
204
+ <div v-else-if="error" class="error-container">
205
+ <el-result icon="error" :title="error">
206
+ <template #extra>
207
+ <el-button type="primary" @click="handleBack">
208
+ {{ t('clone.detail.back') }}
209
+ </el-button>
210
+ </template>
211
+ </el-result>
212
+ </div>
213
+
214
+ <!-- 任务详情内容 -->
215
+ <div v-else-if="task" class="detail-content">
216
+ <!-- 左侧:任务信息和阶段进度 -->
217
+ <div class="detail-left">
218
+ <!-- 任务信息卡片 -->
219
+ <div class="info-card">
220
+ <h3 class="card-title">{{ t('clone.detail.taskInfo') }}</h3>
221
+ <div class="info-grid">
222
+ <div class="info-item">
223
+ <span class="info-label">{{ t('clone.detail.createdAt') }}</span>
224
+ <span class="info-value">{{ formatTime(task.created_at) }}</span>
225
+ </div>
226
+ <div class="info-item">
227
+ <span class="info-label">{{ t('clone.detail.startedAt') }}</span>
228
+ <span class="info-value">{{ formatTime(task.started_at) }}</span>
229
+ </div>
230
+ <div class="info-item">
231
+ <span class="info-label">{{ t('clone.detail.completedAt') }}</span>
232
+ <span class="info-value">{{ formatTime(task.completed_at) }}</span>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- 阶段进度 -->
238
+ <div class="progress-card">
239
+ <h3 class="card-title">{{ t('clone.detail.stageProgress') }}</h3>
240
+ <TaskProgress :task="task" :progress="progress" />
241
+ </div>
242
+ </div>
243
+
244
+ <!-- 右侧:训练日志 -->
245
+ <div class="detail-right">
246
+ <TrainingLogs :logs="logs" :auto-scroll="true" />
247
+ </div>
248
+ </div>
249
+ </div>
250
+ </template>
251
+
252
+ <style lang="scss" scoped>
253
+ .task-detail-page {
254
+ height: 100%;
255
+ display: flex;
256
+ flex-direction: column;
257
+ }
258
+
259
+ .detail-header {
260
+ display: flex;
261
+ justify-content: space-between;
262
+ align-items: center;
263
+ margin-bottom: 24px;
264
+ flex-shrink: 0;
265
+
266
+ .header-left {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 16px;
270
+ }
271
+
272
+ .page-title {
273
+ margin: 0;
274
+ font-size: 24px;
275
+ font-weight: 600;
276
+ color: var(--text-primary);
277
+ }
278
+ }
279
+
280
+ .loading-container,
281
+ .error-container {
282
+ flex: 1;
283
+ display: flex;
284
+ flex-direction: column;
285
+ align-items: center;
286
+ justify-content: center;
287
+ gap: 16px;
288
+ color: var(--text-muted);
289
+ }
290
+
291
+ .detail-content {
292
+ flex: 1;
293
+ display: grid;
294
+ grid-template-columns: 1fr 1fr;
295
+ gap: 24px;
296
+ min-height: 0;
297
+ overflow: hidden;
298
+ }
299
+
300
+ .detail-left {
301
+ display: flex;
302
+ flex-direction: column;
303
+ gap: 20px;
304
+ overflow-y: auto;
305
+ }
306
+
307
+ .detail-right {
308
+ display: flex;
309
+ flex-direction: column;
310
+ min-height: 0;
311
+ }
312
+
313
+ .info-card,
314
+ .progress-card {
315
+ background: var(--bg-primary);
316
+ border-radius: var(--border-radius-lg);
317
+ border: 1px solid var(--border-color-light);
318
+ padding: 20px;
319
+ }
320
+
321
+ .card-title {
322
+ margin: 0 0 16px 0;
323
+ font-size: 15px;
324
+ font-weight: 600;
325
+ color: var(--text-primary);
326
+ }
327
+
328
+ .info-grid {
329
+ display: grid;
330
+ grid-template-columns: repeat(3, 1fr);
331
+ gap: 16px;
332
+ }
333
+
334
+ .info-item {
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 4px;
338
+ }
339
+
340
+ .info-label {
341
+ font-size: 12px;
342
+ color: var(--text-muted);
343
+ }
344
+
345
+ .info-value {
346
+ font-size: 14px;
347
+ color: var(--text-primary);
348
+ }
349
+
350
+ .progress-card {
351
+ flex: 1;
352
+ display: flex;
353
+ flex-direction: column;
354
+ min-height: 0;
355
+
356
+ :deep(.task-progress) {
357
+ flex: 1;
358
+ overflow-y: auto;
359
+ }
360
+ }
361
+
362
+ // 响应式布局
363
+ @media (max-width: 1024px) {
364
+ .detail-content {
365
+ grid-template-columns: 1fr;
366
+ grid-template-rows: auto 1fr;
367
+ }
368
+
369
+ .info-grid {
370
+ grid-template-columns: repeat(2, 1fr);
371
+ }
372
+ }
373
+ </style>
tts-voice-app/src/renderer/src/views/TextToSpeech.vue ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+
5
+ const { t } = useI18n()
6
+
7
+ const text = ref('')
8
+ const maxLength = 5000
9
+ const isGenerating = ref(false)
10
+
11
+ const characterCount = computed(() => text.value.length)
12
+ const canGenerate = computed(() => text.value.trim().length > 0 && !isGenerating.value)
13
+
14
+ const handleGenerate = async () => {
15
+ if (!canGenerate.value) return
16
+ isGenerating.value = true
17
+ // TODO: 调用 TTS API
18
+ setTimeout(() => {
19
+ isGenerating.value = false
20
+ }, 2000)
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div class="tts-page">
26
+ <div class="tts-content">
27
+ <h1 class="page-title">{{ t('tts.title') }}</h1>
28
+
29
+ <div class="tts-main">
30
+ <div class="text-input-area">
31
+ <el-input
32
+ v-model="text"
33
+ type="textarea"
34
+ :placeholder="t('tts.placeholder')"
35
+ :maxlength="maxLength"
36
+ :rows="12"
37
+ resize="none"
38
+ class="text-input"
39
+ />
40
+ <div class="input-footer">
41
+ <span class="char-count">{{ characterCount }}/{{ maxLength }} {{ t('tts.characters') }}</span>
42
+ <el-button
43
+ type="primary"
44
+ :loading="isGenerating"
45
+ :disabled="!canGenerate"
46
+ @click="handleGenerate"
47
+ >
48
+ {{ t('tts.generate') }}
49
+ </el-button>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="tts-settings">
56
+ <h3 class="settings-title">{{ t('tts.settings') }}</h3>
57
+
58
+ <div class="setting-item">
59
+ <label>{{ t('tts.voice') }}</label>
60
+ <el-select placeholder="Select voice" style="width: 100%">
61
+ <el-option label="Default Voice" value="default" />
62
+ </el-select>
63
+ </div>
64
+
65
+ <div class="setting-item">
66
+ <label>{{ t('tts.speed') }}</label>
67
+ <el-slider :min="0.5" :max="2" :step="0.1" :model-value="1" />
68
+ </div>
69
+
70
+ <div class="setting-item">
71
+ <label>{{ t('tts.emotion') }}</label>
72
+ <el-select placeholder="Auto" style="width: 100%">
73
+ <el-option label="Auto" value="auto" />
74
+ <el-option label="Happy" value="happy" />
75
+ <el-option label="Sad" value="sad" />
76
+ <el-option label="Neutral" value="neutral" />
77
+ </el-select>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <style lang="scss" scoped>
84
+ .tts-page {
85
+ display: flex;
86
+ gap: 24px;
87
+ height: 100%;
88
+ }
89
+
90
+ .tts-content {
91
+ flex: 1;
92
+ display: flex;
93
+ flex-direction: column;
94
+ }
95
+
96
+ .tts-main {
97
+ flex: 1;
98
+ }
99
+
100
+ .text-input-area {
101
+ background: var(--bg-secondary);
102
+ border-radius: var(--border-radius-lg);
103
+ padding: 16px;
104
+ }
105
+
106
+ .text-input {
107
+ :deep(.el-textarea__inner) {
108
+ background: transparent;
109
+ border: none;
110
+ box-shadow: none;
111
+ color: var(--text-primary);
112
+ font-size: 15px;
113
+ line-height: 1.6;
114
+
115
+ &::placeholder {
116
+ color: var(--text-muted);
117
+ }
118
+ }
119
+ }
120
+
121
+ .input-footer {
122
+ display: flex;
123
+ justify-content: space-between;
124
+ align-items: center;
125
+ margin-top: 12px;
126
+ padding-top: 12px;
127
+ border-top: 1px solid var(--border-color-light);
128
+ }
129
+
130
+ .char-count {
131
+ font-size: 13px;
132
+ color: var(--text-muted);
133
+ }
134
+
135
+ .tts-settings {
136
+ width: 280px;
137
+ background: var(--bg-secondary);
138
+ border-radius: var(--border-radius-lg);
139
+ padding: 20px;
140
+ height: fit-content;
141
+ }
142
+
143
+ .settings-title {
144
+ font-size: 16px;
145
+ font-weight: 600;
146
+ margin-bottom: 20px;
147
+ color: var(--text-primary);
148
+ }
149
+
150
+ .setting-item {
151
+ margin-bottom: 20px;
152
+
153
+ &:last-child {
154
+ margin-bottom: 0;
155
+ }
156
+
157
+ label {
158
+ display: block;
159
+ font-size: 13px;
160
+ font-weight: 500;
161
+ color: var(--text-secondary);
162
+ margin-bottom: 8px;
163
+ }
164
+ }
165
+ </style>
tts-voice-app/src/renderer/src/views/VoiceClone.vue ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ /**
3
+ * 音色克隆页面
4
+ *
5
+ * 展示训练任务列表,支持创建新任务
6
+ */
7
+
8
+ import { ref, onMounted, onUnmounted, computed } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import { useRouter } from 'vue-router'
11
+ import { Plus } from '@element-plus/icons-vue'
12
+ import { ElMessageBox, ElMessage } from 'element-plus'
13
+ import TaskList from '../components/training/TaskList.vue'
14
+ import CreateTaskDialog from '../components/training/CreateTaskDialog.vue'
15
+ import { useTrainingStore } from '../stores/training'
16
+ import type { TaskResponse } from '../types'
17
+
18
+ const { t } = useI18n()
19
+ const router = useRouter()
20
+ const store = useTrainingStore()
21
+
22
+ // 状态
23
+ const mode = ref<'quick' | 'advanced'>('quick')
24
+ const showCreateDialog = ref(false)
25
+
26
+ // 计算属性
27
+ const tasks = computed(() => store.tasks)
28
+ const progressMap = computed(() => store.progressMap)
29
+ const loading = computed(() => store.loading)
30
+ const total = computed(() => store.total)
31
+ const totalPages = computed(() => store.totalPages)
32
+ const pageSize = computed(() => store.pageSize)
33
+
34
+ // 生命周期
35
+ onMounted(async () => {
36
+ await store.fetchTasks()
37
+ })
38
+
39
+ onUnmounted(() => {
40
+ store.cleanup()
41
+ })
42
+
43
+ // 方法
44
+ function handleCreateTask() {
45
+ showCreateDialog.value = true
46
+ }
47
+
48
+ async function handleTaskCreated(_taskId: string) {
49
+ // 任务创建成功后刷新列表
50
+ await store.fetchTasks()
51
+ }
52
+
53
+ async function handleCancelTask(taskId: string) {
54
+ try {
55
+ await ElMessageBox.confirm(t('clone.confirmCancel'), t('common.confirm'), {
56
+ confirmButtonText: t('common.confirm'),
57
+ cancelButtonText: t('common.cancel'),
58
+ type: 'warning'
59
+ })
60
+
61
+ await store.cancelTask(taskId)
62
+ ElMessage.success(t('clone.taskCancelled'))
63
+ } catch {
64
+ // 用户取消操作或取消失败
65
+ }
66
+ }
67
+
68
+ function handleViewTask(task: TaskResponse) {
69
+ // 跳转到任务详情页面
70
+ router.push(`/clone/${task.id}`)
71
+ }
72
+
73
+ async function handleRefresh() {
74
+ await store.fetchTasks()
75
+ }
76
+
77
+ async function handlePageChange(page: number) {
78
+ await store.changePage(page)
79
+ }
80
+ </script>
81
+
82
+ <template>
83
+ <div class="clone-page">
84
+ <!-- 页面头部 -->
85
+ <div class="clone-header">
86
+ <div class="header-left">
87
+ <h1 class="page-title">{{ t('clone.title') }}</h1>
88
+ <el-radio-group v-model="mode" size="default">
89
+ <el-radio-button value="quick">{{ t('clone.quickMode') }}</el-radio-button>
90
+ <el-radio-button value="advanced" disabled>
91
+ {{ t('clone.advancedMode') }}
92
+ </el-radio-button>
93
+ </el-radio-group>
94
+ </div>
95
+ <el-button type="primary" :icon="Plus" @click="handleCreateTask">
96
+ {{ t('clone.createTask') }}
97
+ </el-button>
98
+ </div>
99
+
100
+ <!-- 任务列表 -->
101
+ <div class="clone-content">
102
+ <TaskList
103
+ :tasks="tasks"
104
+ :progress-map="progressMap"
105
+ :loading="loading"
106
+ @cancel="handleCancelTask"
107
+ @view="handleViewTask"
108
+ @refresh="handleRefresh"
109
+ />
110
+
111
+ <!-- 分页器 -->
112
+ <div v-if="total > 0" class="pagination-wrapper">
113
+ <el-pagination
114
+ :current-page="store.currentPage"
115
+ :page-size="pageSize"
116
+ :total="total"
117
+ :page-count="totalPages"
118
+ layout="total, prev, pager, next"
119
+ background
120
+ @current-change="handlePageChange"
121
+ />
122
+ </div>
123
+ </div>
124
+
125
+ <!-- 创建任务对话框 -->
126
+ <CreateTaskDialog v-model="showCreateDialog" @created="handleTaskCreated" />
127
+ </div>
128
+ </template>
129
+
130
+ <style lang="scss" scoped>
131
+ .clone-page {
132
+ height: 100%;
133
+ display: flex;
134
+ flex-direction: column;
135
+ }
136
+
137
+ .clone-header {
138
+ display: flex;
139
+ justify-content: space-between;
140
+ align-items: center;
141
+ margin-bottom: 24px;
142
+ flex-shrink: 0;
143
+
144
+ .header-left {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 20px;
148
+ }
149
+
150
+ .page-title {
151
+ margin-bottom: 0;
152
+ font-size: 24px;
153
+ font-weight: 600;
154
+ color: var(--text-primary);
155
+ }
156
+ }
157
+
158
+ .clone-content {
159
+ flex: 1;
160
+ display: flex;
161
+ flex-direction: column;
162
+ min-height: 0;
163
+ overflow: hidden;
164
+ }
165
+
166
+ .pagination-wrapper {
167
+ display: flex;
168
+ justify-content: center;
169
+ padding-top: 24px;
170
+ margin-top: auto;
171
+ flex-shrink: 0;
172
+ }
173
+ </style>
tts-voice-app/src/renderer/src/views/VoiceLibrary.vue ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import type { Voice } from '@/types'
5
+
6
+ const { t } = useI18n()
7
+
8
+ const searchQuery = ref('')
9
+ const voices = ref<Voice[]>([])
10
+
11
+ // TODO: 从 API 获取音色列表
12
+ </script>
13
+
14
+ <template>
15
+ <div class="library-page">
16
+ <div class="library-header">
17
+ <h1 class="page-title">{{ t('library.title') }}</h1>
18
+ <div class="header-actions">
19
+ <el-button type="primary">
20
+ <el-icon><Plus /></el-icon>
21
+ {{ t('library.import') }}
22
+ </el-button>
23
+ <el-input
24
+ v-model="searchQuery"
25
+ :placeholder="t('library.search')"
26
+ style="width: 200px"
27
+ clearable
28
+ >
29
+ <template #prefix>
30
+ <el-icon><Search /></el-icon>
31
+ </template>
32
+ </el-input>
33
+ </div>
34
+ </div>
35
+
36
+ <div v-if="voices.length === 0" class="empty-state">
37
+ <el-icon :size="64" color="var(--text-muted)"><Microphone /></el-icon>
38
+ <p>{{ t('library.empty') }}</p>
39
+ </div>
40
+
41
+ <div v-else class="voice-grid">
42
+ <div v-for="voice in voices" :key="voice.id" class="voice-card">
43
+ <div class="voice-avatar">
44
+ <el-icon :size="32"><User /></el-icon>
45
+ </div>
46
+ <div class="voice-info">
47
+ <h4>{{ voice.name }}</h4>
48
+ <span class="voice-meta">{{ voice.language }} • {{ voice.version }}</span>
49
+ </div>
50
+ <div class="voice-actions">
51
+ <el-button circle size="small">
52
+ <el-icon><VideoPlay /></el-icon>
53
+ </el-button>
54
+ <el-dropdown>
55
+ <el-button circle size="small">
56
+ <el-icon><MoreFilled /></el-icon>
57
+ </el-button>
58
+ <template #dropdown>
59
+ <el-dropdown-menu>
60
+ <el-dropdown-item>{{ t('library.edit') }}</el-dropdown-item>
61
+ <el-dropdown-item>{{ t('library.export') }}</el-dropdown-item>
62
+ <el-dropdown-item divided>{{ t('library.delete') }}</el-dropdown-item>
63
+ </el-dropdown-menu>
64
+ </template>
65
+ </el-dropdown>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <script lang="ts">
73
+ import { Plus, Search, Microphone, User, VideoPlay, MoreFilled } from '@element-plus/icons-vue'
74
+ export default {
75
+ components: { Plus, Search, Microphone, User, VideoPlay, MoreFilled }
76
+ }
77
+ </script>
78
+
79
+ <style lang="scss" scoped>
80
+ .library-page {
81
+ height: 100%;
82
+ }
83
+
84
+ .library-header {
85
+ display: flex;
86
+ justify-content: space-between;
87
+ align-items: center;
88
+ margin-bottom: 24px;
89
+
90
+ .page-title {
91
+ margin-bottom: 0;
92
+ }
93
+ }
94
+
95
+ .header-actions {
96
+ display: flex;
97
+ gap: 12px;
98
+ }
99
+
100
+ .empty-state {
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ justify-content: center;
105
+ height: 400px;
106
+ color: var(--text-muted);
107
+
108
+ p {
109
+ margin-top: 16px;
110
+ font-size: 15px;
111
+ }
112
+ }
113
+
114
+ .voice-grid {
115
+ display: grid;
116
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
117
+ gap: 16px;
118
+ }
119
+
120
+ .voice-card {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 12px;
124
+ padding: 16px;
125
+ background: var(--bg-secondary);
126
+ border-radius: var(--border-radius);
127
+ transition: box-shadow var(--transition-fast);
128
+
129
+ &:hover {
130
+ box-shadow: 0 4px 12px var(--shadow-color);
131
+ }
132
+ }
133
+
134
+ .voice-avatar {
135
+ width: 48px;
136
+ height: 48px;
137
+ border-radius: 50%;
138
+ background: var(--primary-color);
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ color: white;
143
+ }
144
+
145
+ .voice-info {
146
+ flex: 1;
147
+
148
+ h4 {
149
+ font-size: 15px;
150
+ font-weight: 600;
151
+ margin-bottom: 4px;
152
+ }
153
+
154
+ .voice-meta {
155
+ font-size: 12px;
156
+ color: var(--text-muted);
157
+ }
158
+ }
159
+
160
+ .voice-actions {
161
+ display: flex;
162
+ gap: 8px;
163
+ }
164
+ </style>
tts-voice-app/tsconfig.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "preserve",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["src/renderer/src/*"]
21
+ }
22
+ },
23
+ "include": [
24
+ "src/**/*.ts",
25
+ "src/**/*.d.ts",
26
+ "src/**/*.tsx",
27
+ "src/**/*.vue"
28
+ ],
29
+ "references": [{ "path": "./tsconfig.node.json" }]
30
+ }
tts-voice-app/tsconfig.node.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["electron.vite.config.ts"]
11
+ }