Files
LittleWhiteBox/modules/tts/tts-overlay.html

2480 lines
95 KiB
HTML
Raw Permalink Normal View History

2026-01-17 16:34:39 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
2026-01-18 03:06:38 +08:00
<title>TTS 语音设置</title>
2026-01-17 16:34:39 +08:00
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
:root {
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
极简科技风配色 - 黑白灰 + 单色点缀
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-18 03:06:38 +08:00
/* 背景层次 */
2026-01-18 01:48:30 +08:00
--bg-primary: #0a0a0c;
--bg-secondary: #111114;
--bg-tertiary: #18181c;
--bg-elevated: #1e1e24;
--bg-input: rgba(255, 255, 255, 0.04);
2026-01-18 03:06:38 +08:00
/* 文字层次 */
2026-01-18 01:48:30 +08:00
--text-primary: #f0f0f2;
--text-secondary: #a0a0a8;
--text-muted: #606068;
--text-dim: #404048;
2026-01-18 03:06:38 +08:00
/* 边框 */
2026-01-18 01:48:30 +08:00
--border: rgba(255, 255, 255, 0.08);
--border-light: rgba(255, 255, 255, 0.12);
--border-focus: rgba(140, 200, 255, 0.4);
2026-01-18 03:06:38 +08:00
/* 唯一强调色 - 淡青蓝(科技感) */
2026-01-18 01:48:30 +08:00
--accent: #8cc8ff;
--accent-soft: rgba(140, 200, 255, 0.1);
--accent-glow: rgba(140, 200, 255, 0.15);
2026-01-18 03:06:38 +08:00
/* 功能色 - 极低饱和度 */
2026-01-18 01:48:30 +08:00
--success: #90d4a0;
--success-soft: rgba(144, 212, 160, 0.08);
--error: #e08080;
--error-soft: rgba(224, 128, 128, 0.08);
2026-01-18 03:06:38 +08:00
/* 试用/鉴权 - 不用彩色,用明暗区分 */
2026-01-18 01:48:30 +08:00
--tag-free: rgba(255, 255, 255, 0.7);
--tag-free-bg: rgba(255, 255, 255, 0.06);
--tag-auth: var(--accent);
--tag-auth-bg: var(--accent-soft);
/* Safe Area */
2026-01-17 16:34:39 +08:00
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
html {
height: 100%;
height: -webkit-fill-available;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
min-height: 100%;
min-height: -webkit-fill-available;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
2026-01-18 01:48:30 +08:00
min-height: 100dvh;
2026-01-17 16:34:39 +08:00
min-height: -webkit-fill-available;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Header
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.app-header {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 12px;
2026-01-17 16:34:39 +08:00
padding: 12px 20px;
padding-top: calc(12px + var(--safe-area-top));
padding-left: calc(20px + var(--safe-area-left));
padding-right: calc(20px + var(--safe-area-right));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
flex-shrink: 0;
}
2026-01-18 01:48:30 +08:00
.header-logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
color: var(--text-primary);
}
.header-logo i {
color: var(--accent);
opacity: 0.9;
}
.header-status {
display: flex;
align-items: center;
gap: 8px;
}
2026-01-18 23:03:36 +08:00
.header-toggles {
display: flex;
gap: 6px;
}
.header-toggle {
display: flex;
align-items: center;
gap: 4px;
2026-01-18 23:20:04 +08:00
padding: 4px 8px;
2026-01-18 23:03:36 +08:00
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.header-toggle:hover {
border-color: var(--border-light);
color: var(--text-primary);
}
.header-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
2026-01-18 23:20:04 +08:00
margin: 0;
2026-01-18 23:03:36 +08:00
}
2026-01-17 16:34:39 +08:00
.header-badge {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 10px;
color: var(--text-muted);
transition: all 0.2s;
}
.header-badge i {
font-size: 5px;
opacity: 0.5;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.header-badge.active {
color: var(--text-secondary);
border-color: var(--border-light);
}
.header-badge.active i {
color: var(--accent);
opacity: 1;
}
2026-01-17 16:34:39 +08:00
.header-spacer { flex: 1; min-width: 10px; }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.header-close {
width: 36px; height: 36px; min-width: 36px;
2026-01-18 01:48:30 +08:00
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s;
}
.header-close:hover {
background: rgba(255,255,255,0.05);
color: var(--text-primary);
border-color: var(--border-light);
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Layout
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-17 16:34:39 +08:00
.app-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.app-sidebar {
2026-01-18 01:48:30 +08:00
width: 200px;
min-width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 16px 8px;
2026-01-17 16:34:39 +08:00
padding-left: calc(8px + var(--safe-area-left));
2026-01-18 01:48:30 +08:00
display: flex;
flex-direction: column;
gap: 4px;
2026-01-17 16:34:39 +08:00
overflow-y: auto;
flex-shrink: 0;
}
.app-main {
flex: 1;
padding: 24px;
padding-right: calc(24px + var(--safe-area-right));
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Navigation
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.nav-item {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
font-size: 13px;
}
.nav-item:hover {
background: rgba(255,255,255,0.03);
color: var(--text-secondary);
}
.nav-item.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 500;
2026-01-17 16:34:39 +08:00
}
.nav-item i { width: 18px; text-align: center; }
2026-01-18 01:48:30 +08:00
.nav-divider {
height: 1px;
background: var(--border);
margin: 8px 0;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Views
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
.view {
display: none;
max-width: 800px;
margin: 0 auto;
padding-bottom: 24px;
}
.view.active {
display: block;
animation: viewIn 0.2s ease;
}
@keyframes viewIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; }
}
2026-01-17 16:34:39 +08:00
.view-header { margin-bottom: 20px; }
2026-01-18 01:48:30 +08:00
.view-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
.view-desc {
font-size: 13px;
color: var(--text-muted);
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Cards
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-17 16:34:39 +08:00
.card {
2026-01-18 01:48:30 +08:00
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.card-title {
2026-01-18 01:48:30 +08:00
font-size: 11px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
2026-01-17 16:34:39 +08:00
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Forms
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
2026-01-18 01:48:30 +08:00
.form-label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.form-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
2026-01-17 16:34:39 +08:00
.input {
2026-01-18 01:48:30 +08:00
width: 100%;
padding: 10px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
transition: all 0.15s;
}
.input:focus {
outline: none;
border-color: var(--border-focus);
background: rgba(255,255,255,0.06);
}
.input::placeholder { color: var(--text-dim); }
textarea.input {
min-height: 80px;
resize: vertical;
font-family: inherit;
2026-01-17 16:34:39 +08:00
}
select.input { cursor: pointer; }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.input-row { display: flex; gap: 8px; }
.input-row .input { flex: 1; min-width: 0; }
.checkbox-row {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
2026-01-17 16:34:39 +08:00
}
.checkbox-row input[type="checkbox"] {
2026-01-18 01:48:30 +08:00
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.checkbox-row label {
font-size: 13px;
cursor: pointer;
color: var(--text-secondary);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Buttons
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-17 16:34:39 +08:00
.btn {
2026-01-18 01:48:30 +08:00
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
min-height: 40px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-light);
2026-01-17 16:34:39 +08:00
}
.btn:active { transform: scale(0.98); }
2026-01-18 01:48:30 +08:00
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--accent-soft);
border-color: rgba(140, 200, 255, 0.2);
color: var(--accent);
font-weight: 500;
}
.btn-primary:hover {
background: rgba(140, 200, 255, 0.15);
border-color: rgba(140, 200, 255, 0.3);
}
.btn-danger {
color: var(--error);
border-color: rgba(224, 128, 128, 0.2);
}
.btn-danger:hover {
background: var(--error-soft);
}
2026-01-17 16:34:39 +08:00
.btn-icon { width: 40px; padding: 0; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
.btn-sm { padding: 6px 10px; min-height: 32px; font-size: 12px; }
.btn-xs { padding: 4px 8px; min-height: 28px; font-size: 11px; }
.btn.saving { pointer-events: none; opacity: 0.7; }
2026-01-18 01:48:30 +08:00
.btn.save-success {
background: var(--success-soft) !important;
border-color: rgba(144, 212, 160, 0.3) !important;
color: var(--success) !important;
pointer-events: none;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Slider
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-17 16:34:39 +08:00
.slider-row { display: flex; align-items: center; gap: 12px; }
2026-01-18 01:48:30 +08:00
.slider-row input[type="range"] {
flex: 1;
height: 4px;
accent-color: var(--accent);
cursor: pointer;
opacity: 0.8;
}
.slider-row input[type="range"]:hover { opacity: 1; }
.slider-row .slider-val {
min-width: 50px;
text-align: right;
font-size: 13px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Rules Editor
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
.rules-editor {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
2026-01-17 16:34:39 +08:00
.rules-header {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.rules-header-title {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
2026-01-17 16:34:39 +08:00
.rules-list { max-height: 200px; overflow-y: auto; }
2026-01-18 01:48:30 +08:00
.rules-empty {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-size: 12px;
}
2026-01-17 16:34:39 +08:00
.rule-item {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
2026-01-17 16:34:39 +08:00
}
.rule-item:last-child { border-bottom: none; }
.rule-item:hover { background: rgba(255,255,255,0.02); }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.rule-input {
2026-01-18 01:48:30 +08:00
flex: 1;
padding: 6px 10px;
min-width: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.rule-input:focus {
outline: none;
border-color: var(--border-focus);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.rule-input::placeholder { color: var(--text-dim); }
.rule-arrow {
color: var(--text-dim);
font-size: 11px;
flex-shrink: 0;
}
.rule-delete {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.rule-delete:hover {
background: var(--error-soft);
color: var(--error);
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
Current Voice Card - 极简版
═══════════════════════════════════════════════════════════════ */
2026-01-17 16:34:39 +08:00
.current-voice-card {
2026-01-18 01:48:30 +08:00
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
transition: all 0.2s;
}
.current-voice-card:hover {
border-color: var(--border-light);
}
.current-voice-label {
font-size: 10px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.current-voice-display {
display: flex;
align-items: center;
gap: 14px;
}
2026-01-17 16:34:39 +08:00
.current-voice-icon {
2026-01-18 01:48:30 +08:00
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s;
}
.current-voice-card:hover .current-voice-icon {
color: var(--accent);
border-color: rgba(140, 200, 255, 0.2);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.current-voice-info { flex: 1; }
2026-01-18 01:48:30 +08:00
.current-voice-name {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
}
.current-voice-source {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
Source Badge - 极简黑白版
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.source-badge {
2026-01-18 01:48:30 +08:00
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.02em;
}
2026-01-18 03:06:38 +08:00
/* 试用 - 白色/浅色调 */
2026-01-18 01:48:30 +08:00
.source-badge.trial {
background: var(--tag-free-bg);
color: var(--tag-free);
border: 1px solid rgba(255, 255, 255, 0.1);
2026-01-17 16:34:39 +08:00
}
2026-01-18 03:06:38 +08:00
/* 鉴权 - 蓝色点缀 */
2026-01-18 01:48:30 +08:00
.source-badge.auth {
background: var(--tag-auth-bg);
color: var(--tag-auth);
border: 1px solid rgba(140, 200, 255, 0.15);
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
Voice Tabs - 极简版
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.voice-tabs {
2026-01-18 01:48:30 +08:00
display: flex;
gap: 2px;
margin-bottom: 16px;
background: var(--bg-tertiary);
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.voice-tab {
2026-01-18 01:48:30 +08:00
flex: 1;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.voice-tab:hover {
color: var(--text-secondary);
}
.voice-tab.active {
background: var(--bg-elevated);
color: var(--text-primary);
font-weight: 500;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.voice-tab-count {
2026-01-18 01:48:30 +08:00
background: var(--bg-input);
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
color: var(--text-dim);
}
.voice-tab.active .voice-tab-count {
background: var(--accent-soft);
color: var(--accent);
2026-01-17 16:34:39 +08:00
}
.voice-panel { display: none; }
.voice-panel.active { display: block; }
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Test Voice Box
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.test-voice-box {
2026-01-18 01:48:30 +08:00
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
margin-bottom: 16px;
}
.test-voice-row {
display: flex;
gap: 8px;
align-items: center;
2026-01-17 16:34:39 +08:00
}
.test-voice-row .input { flex: 1; }
2026-01-18 01:48:30 +08:00
.test-voice-status {
font-size: 11px;
color: var(--text-dim);
margin-top: 6px;
min-height: 16px;
}
.test-voice-status.playing { color: var(--accent); }
.test-voice-status.error { color: var(--error); }
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Voice Filters
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
.voice-filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
2026-01-17 16:34:39 +08:00
.voice-filters select {
2026-01-18 01:48:30 +08:00
flex: 1;
min-width: 80px;
padding: 8px 10px;
font-size: 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
}
.voice-filters select:focus {
outline: none;
border-color: var(--border-focus);
}
.voice-search {
display: flex;
gap: 8px;
margin-bottom: 12px;
2026-01-17 16:34:39 +08:00
}
.voice-search .input { flex: 1; }
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
Voice List - 极简版
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
.voice-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
2026-01-17 16:34:39 +08:00
.voice-item {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.voice-item:hover {
border-color: var(--border-light);
background: var(--bg-elevated);
}
.voice-item.selected {
border-color: var(--accent);
background: var(--accent-soft);
}
.voice-item.in-my-list {
opacity: 0.5;
}
.voice-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.voice-item.disabled:hover {
border-color: var(--border);
background: var(--bg-tertiary);
}
2026-01-17 16:34:39 +08:00
.voice-item-radio {
2026-01-18 01:48:30 +08:00
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--text-dim);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.voice-item:hover .voice-item-radio {
border-color: var(--text-muted);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.voice-item.selected .voice-item-radio {
border-color: var(--accent);
background: var(--accent);
}
.voice-item.selected .voice-item-radio::after {
2026-01-18 03:06:38 +08:00
content: '✓';
2026-01-18 01:48:30 +08:00
color: var(--bg-primary);
font-size: 9px;
font-weight: bold;
}
2026-01-17 16:34:39 +08:00
.voice-item-info { flex: 1; min-width: 0; }
2026-01-18 01:48:30 +08:00
.voice-item-name {
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: var(--text-primary);
}
.voice-item-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.voice-item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* Editing State */
.voice-item.editing {
background: var(--accent-soft);
border-color: var(--accent);
}
.voice-item-edit-form {
display: none;
width: 100%;
margin-top: 8px;
}
.voice-item.editing .voice-item-edit-form {
display: flex;
gap: 8px;
}
.voice-item.editing .voice-item-info > *:not(.voice-item-edit-form) {
display: none;
}
.voice-item-edit-form input {
flex: 1;
padding: 6px 10px;
font-size: 12px;
}
/* Add Form */
.voice-add-form {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.voice-add-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.voice-add-row .form-group {
flex: 1;
margin-bottom: 0;
}
.voice-add-row .form-label {
font-size: 11px;
margin-bottom: 4px;
}
.preset-save-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 12px;
}
.preset-save-row input {
flex: 1;
padding: 10px 12px;
font-size: 13px;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
API Status Box
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.api-status-box {
2026-01-18 01:48:30 +08:00
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
2026-01-17 16:34:39 +08:00
margin-bottom: 16px;
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.api-status-icon {
2026-01-18 01:48:30 +08:00
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
2026-01-17 16:34:39 +08:00
font-size: 14px;
2026-01-18 01:48:30 +08:00
background: var(--bg-elevated);
border: 1px solid var(--border);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.api-status-box.configured .api-status-icon {
color: var(--accent);
border-color: rgba(140, 200, 255, 0.2);
}
.api-status-box.not-configured .api-status-icon {
color: var(--text-dim);
}
2026-01-17 16:34:39 +08:00
.api-status-info { flex: 1; }
2026-01-18 01:48:30 +08:00
.api-status-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.api-status-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Stats Card
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
.stats-card {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.stats-group {
display: flex;
gap: 32px;
}
2026-01-17 16:34:39 +08:00
.stats-item { text-align: center; }
2026-01-18 01:48:30 +08:00
.stats-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stats-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
Tip Box - 极简版
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.tip-box {
2026-01-18 01:48:30 +08:00
display: flex;
gap: 10px;
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.tip-box i {
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
opacity: 0.7;
}
.tip-box.warning {
border-left: 3px solid var(--accent);
}
.tip-box.warning i {
color: var(--accent);
2026-01-17 16:34:39 +08:00
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Guide Box
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.guide-box {
2026-01-18 01:48:30 +08:00
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.guide-box h3 {
2026-01-18 01:48:30 +08:00
font-size: 14px;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.guide-box h3 i {
color: var(--accent);
opacity: 0.8;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.guide-box p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
line-height: 1.6;
}
.guide-box ol, .guide-box ul {
margin-left: 18px;
font-size: 13px;
color: var(--text-secondary);
}
.guide-box li {
margin-bottom: 8px;
line-height: 1.6;
}
2026-01-17 16:34:39 +08:00
.guide-box a { color: var(--accent); }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.guide-box code {
2026-01-18 01:48:30 +08:00
background: var(--bg-input);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: var(--accent);
font-family: monospace;
border: 1px solid var(--border);
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.guide-box pre {
2026-01-18 01:48:30 +08:00
background: var(--bg-primary);
padding: 12px;
border-radius: 6px;
font-size: 11px;
color: var(--text-secondary);
font-family: monospace;
overflow-x: auto;
margin: 10px 0;
line-height: 1.5;
2026-01-17 16:34:39 +08:00
border: 1px solid var(--border);
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.guide-image {
2026-01-18 01:48:30 +08:00
margin-top: 12px;
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 8px;
display: block;
opacity: 0.9;
2026-01-17 16:34:39 +08:00
}
2026-01-18 01:48:30 +08:00
.guide-image:hover { opacity: 1; }
2026-01-17 16:34:39 +08:00
.guide-link {
2026-01-18 01:48:30 +08:00
display: block;
padding: 10px 12px;
margin: 10px 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
word-break: break-all;
2026-01-17 16:34:39 +08:00
}
.guide-link a { color: var(--accent); }
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Mobile Navigation
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(60px + var(--safe-area-bottom));
padding-bottom: var(--safe-area-bottom);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
z-index: 100;
}
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.mobile-nav-inner {
display: flex;
height: 60px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
}
.mobile-nav-inner::-webkit-scrollbar { display: none; }
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
.mobile-nav-item {
2026-01-18 01:48:30 +08:00
flex: 1;
min-width: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--text-dim);
font-size: 10px;
cursor: pointer;
padding: 8px 4px;
transition: color 0.15s;
2026-01-17 16:34:39 +08:00
}
.mobile-nav-item i { font-size: 18px; }
2026-01-18 01:48:30 +08:00
.mobile-nav-item:hover { color: var(--text-muted); }
2026-01-17 16:34:39 +08:00
.mobile-nav-item.active { color: var(--accent); }
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Responsive
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
@media (max-width: 768px) {
.app-sidebar { display: none; }
.mobile-nav { display: block; }
.app-body {
padding-bottom: calc(60px + var(--safe-area-bottom));
}
.app-main {
padding: 16px;
padding-left: calc(16px + var(--safe-area-left));
padding-right: calc(16px + var(--safe-area-right));
padding-bottom: calc(80px + var(--safe-area-bottom));
}
2026-01-18 01:48:30 +08:00
.view { padding-bottom: 20px; }
2026-01-17 16:34:39 +08:00
.app-header {
padding: 10px 12px;
padding-top: calc(10px + var(--safe-area-top));
padding-left: calc(12px + var(--safe-area-left));
padding-right: calc(12px + var(--safe-area-right));
gap: 8px;
}
.header-logo span { display: none; }
.header-badge span { display: none; }
.header-close { width: 32px; height: 32px; min-width: 32px; font-size: 14px; }
.view-title { font-size: 18px; }
.card { padding: 16px; }
.form-row { grid-template-columns: 1fr; }
.voice-filters select { min-width: calc(50% - 4px); }
.stats-card { flex-direction: column; align-items: stretch; }
.stats-group { justify-content: space-around; }
.voice-add-row { flex-wrap: wrap; }
.voice-add-row .form-group { min-width: calc(50% - 4px); }
.preset-save-row { flex-wrap: wrap; }
.preset-save-row input { min-width: 100%; margin-bottom: 8px; }
.voice-tabs { flex-wrap: wrap; }
.voice-tab { min-width: calc(33% - 3px); font-size: 11px; padding: 8px 6px; }
}
@media (max-width: 400px) {
.app-header { padding: 8px 10px; }
.mobile-nav-item { min-width: 48px; font-size: 9px; }
.mobile-nav-item i { font-size: 16px; }
}
@media (hover: none) and (pointer: coarse) {
.btn { min-height: 44px; }
.input { min-height: 44px; padding: 12px; }
.nav-item { min-height: 44px; }
.header-close { width: 44px; height: 44px; min-width: 44px; }
.mobile-nav-item { min-height: 44px; }
}
2026-01-18 03:06:38 +08:00
/* ═══════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
Scrollbar
2026-01-18 03:06:38 +08:00
═══════════════════════════════════════════════════════════════ */
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
2026-01-18 01:48:30 +08:00
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.15);
}
2026-01-17 16:34:39 +08:00
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="app-container">
<header class="app-header">
2026-01-18 03:06:38 +08:00
<div class="header-logo"><i class="fa-solid fa-microphone"></i><span>TTS 语音</span></div>
2026-01-17 16:34:39 +08:00
<div class="header-status">
2026-01-18 03:06:38 +08:00
<div id="badge_trial" class="header-badge active"><i class="fa-solid fa-circle"></i><span>试用</span></div>
<div id="badge_auth" class="header-badge"><i class="fa-solid fa-circle"></i><span>鉴权</span></div>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 23:20:04 +08:00
<div class="header-spacer"></div>
2026-01-18 23:03:36 +08:00
<div class="header-toggles">
<label class="header-toggle" title="消息楼层内显示播放器">
<input type="checkbox" id="showFloorButton" checked>
<span>楼层</span>
</label>
<label class="header-toggle" title="屏幕固定位置显示播放器">
<input type="checkbox" id="showFloatingButton">
<span>悬浮</span>
</label>
</div>
2026-01-18 03:06:38 +08:00
<button id="tts_close" class="header-close"></button>
2026-01-17 16:34:39 +08:00
</header>
<div class="app-body">
<nav class="app-sidebar">
2026-01-18 03:06:38 +08:00
<div class="nav-item active" data-view="config"><i class="fa-solid fa-sliders"></i>基础配置</div>
<div class="nav-item" data-view="voice"><i class="fa-solid fa-user-astronaut"></i>音色管理</div>
2026-01-17 16:34:39 +08:00
<div class="nav-divider"></div>
2026-01-18 03:06:38 +08:00
<div class="nav-item" data-view="advanced"><i class="fa-solid fa-gear"></i>高级设置</div>
<div class="nav-item" data-view="cache"><i class="fa-solid fa-database"></i>缓存管理</div>
2026-01-17 16:34:39 +08:00
<div class="nav-divider"></div>
2026-01-18 03:06:38 +08:00
<div class="nav-item" data-view="guide"><i class="fa-solid fa-circle-question"></i>使用说明</div>
2026-01-17 16:34:39 +08:00
</nav>
<main class="app-main">
2026-01-18 03:06:38 +08:00
<!-- 基础配置 -->
2026-01-17 16:34:39 +08:00
<div id="view-config" class="view active">
<div class="view-header">
2026-01-18 03:06:38 +08:00
<h2 class="view-title">基础配置</h2>
<p class="view-desc">TTS 服务连接与朗读设置</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="tip-box" style="margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i>
<div>
2026-01-18 03:06:38 +08:00
<strong>试用音色</strong> — 无需配置使用插件服务器11个音色<br>
<strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻)
2026-01-18 02:55:49 +08:00
</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">鉴权配置(可选)</div>
2026-01-17 16:34:39 +08:00
<div id="apiStatusBox" class="api-status-box not-configured">
2026-01-18 01:48:30 +08:00
<div class="api-status-icon"><i class="fa-solid fa-link-slash"></i></div>
2026-01-17 16:34:39 +08:00
<div class="api-status-info">
2026-01-18 03:06:38 +08:00
<div class="api-status-title">未配置</div>
<div class="api-status-desc">配置后可使用预设音色库和复刻音色</div>
2026-01-17 16:34:39 +08:00
</div>
</div>
<div class="form-group">
<label class="form-label">AppID</label>
2026-01-18 03:06:38 +08:00
<input type="text" id="appId" class="input" placeholder="火山引擎 AppID">
2026-01-17 16:34:39 +08:00
</div>
<div class="form-group">
<label class="form-label">Access Token</label>
<div class="input-row">
2026-01-18 03:06:38 +08:00
<input type="password" id="accessKey" class="input" placeholder="火山引擎 Access Token">
2026-01-17 16:34:39 +08:00
<button id="toggleKey" class="btn btn-icon"><i class="fa-solid fa-eye"></i></button>
</div>
2026-01-18 03:06:38 +08:00
<p class="form-hint">获取方式见「使用说明」页</p>
2026-01-17 16:34:39 +08:00
</div>
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">朗读设置</div>
2026-01-17 16:34:39 +08:00
<div class="checkbox-row">
<input type="checkbox" id="autoSpeak" checked>
2026-01-18 03:06:38 +08:00
<label for="autoSpeak">AI 回复后自动朗读</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">语速</label>
2026-01-17 16:34:39 +08:00
<div class="slider-row">
<input type="range" id="speechRate" min="0.5" max="2.0" step="0.1" value="1.0">
<span class="slider-val" id="speechRateValue">1.0x</span>
</div>
</div>
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">文本过滤</div>
2026-01-17 16:34:39 +08:00
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">跳过区间</label>
<p class="form-hint" style="margin-bottom: 8px;">遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。</p>
2026-01-17 16:34:39 +08:00
<div class="rules-editor">
<div class="rules-header">
2026-01-18 03:06:38 +08:00
<span class="rules-header-title">当前规则</span>
<button class="btn btn-sm" id="addSkipRule"><i class="fa-solid fa-plus"></i> 添加</button>
2026-01-17 16:34:39 +08:00
</div>
<div class="rules-list" id="skipRulesList"></div>
</div>
</div>
<div class="form-group">
<div class="checkbox-row" style="margin-bottom: 8px;">
<input type="checkbox" id="readRangesEnabled">
2026-01-18 03:06:38 +08:00
<label for="readRangesEnabled">启用只读区间(仅朗读匹配内容)</label>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 03:06:38 +08:00
<p class="form-hint" style="margin-bottom: 8px;">起始或结束可单独留空。</p>
2026-01-17 16:34:39 +08:00
<div class="rules-editor">
<div class="rules-header">
2026-01-18 03:06:38 +08:00
<span class="rules-header-title">只读规则</span>
<button class="btn btn-sm" id="addReadRule"><i class="fa-solid fa-plus"></i> 添加</button>
2026-01-17 16:34:39 +08:00
</div>
<div class="rules-list" id="readRulesList"></div>
</div>
</div>
</div>
2026-01-18 03:06:38 +08:00
<button id="saveConfigBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> 保存配置</button>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 03:06:38 +08:00
<!-- 音色管理 -->
2026-01-17 16:34:39 +08:00
<div id="view-voice" class="view">
<div class="view-header">
2026-01-18 03:06:38 +08:00
<h2 class="view-title">音色管理</h2>
<p class="view-desc">将喜欢的音色重命名加入【我的音色】</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="current-voice-card" id="currentVoiceCard">
2026-01-18 03:06:38 +08:00
<div class="current-voice-label">当前默认音色</div>
2026-01-17 16:34:39 +08:00
<div class="current-voice-display">
<div class="current-voice-icon"><i class="fa-solid fa-microphone-lines"></i></div>
<div class="current-voice-info">
2026-01-18 03:06:38 +08:00
<div class="current-voice-name" id="currentVoiceName">未选择</div>
<div class="current-voice-source" id="currentVoiceSource">请在下方选择音色</div>
2026-01-17 16:34:39 +08:00
</div>
</div>
</div>
<div class="voice-tabs">
<button class="voice-tab active" data-panel="myVoice">
2026-01-18 03:06:38 +08:00
<i class="fa-solid fa-star"></i> 我的
2026-01-17 16:34:39 +08:00
<span class="voice-tab-count" id="myVoiceCount">0</span>
</button>
<button class="voice-tab" data-panel="trialVoice">
2026-01-18 03:06:38 +08:00
<i class="fa-solid fa-flask"></i> 试用
2026-01-17 16:34:39 +08:00
</button>
<button class="voice-tab" data-panel="authVoice">
2026-01-18 03:06:38 +08:00
<i class="fa-solid fa-list"></i> 预设库
2026-01-18 02:55:49 +08:00
</button>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 03:06:38 +08:00
<!-- 我的音色面板 -->
2026-01-17 16:34:39 +08:00
<div class="voice-panel active" id="panel-myVoice">
<div class="card">
<div class="test-voice-box">
<div class="test-voice-row">
2026-01-18 03:06:38 +08:00
<input type="text" id="testTextMy" class="input" value="♪ 我能想到最浪漫的事~♪" placeholder="输入测试文本">
<button class="btn btn-primary" id="testMyVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
2026-01-17 16:34:39 +08:00
</div>
<div class="test-voice-status" id="testMyStatus"></div>
</div>
<p class="form-hint" style="margin-bottom: 12px;">
2026-01-18 03:06:38 +08:00
点击选中设为默认。<span class="source-badge trial">试用</span> 无需配置,<span class="source-badge auth">鉴权</span> 需配置 API
2026-01-17 16:34:39 +08:00
</p>
<div class="voice-list" id="myVoiceList"></div>
<div id="myVoiceEmpty" class="rules-empty">
2026-01-18 01:48:30 +08:00
<i class="fa-solid fa-inbox" style="font-size: 24px; margin-bottom: 8px; display: block; opacity: 0.5;"></i>
2026-01-18 03:06:38 +08:00
暂无音色,请从「试用」或「预设库」添加
2026-01-18 02:55:49 +08:00
</div>
2026-01-17 16:34:39 +08:00
<div class="voice-add-form">
2026-01-18 03:06:38 +08:00
<div class="form-label" style="margin-bottom: 8px;">手动添加复刻音色 <span class="source-badge auth">鉴权</span></div>
2026-01-17 16:34:39 +08:00
<div class="voice-add-row">
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">音色 ID</label>
<input type="text" id="newVoiceId" class="input" placeholder="如 S_xxx">
2026-01-17 16:34:39 +08:00
</div>
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">名称</label>
<input type="text" id="newVoiceName" class="input" placeholder="显示名称">
2026-01-17 16:34:39 +08:00
</div>
2026-01-26 01:16:35 +08:00
<div class="form-group">
<label class="form-label">复刻版本</label>
<select id="newVoiceResourceId" class="input">
<option value="seed-icl-2.0">复刻 2.0</option>
<option value="seed-icl-1.0">复刻 1.0</option>
</select>
</div>
2026-01-17 16:34:39 +08:00
<button class="btn btn-primary" id="addMySpeakerBtn" style="margin-top: 18px;"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
</div>
</div>
2026-01-18 03:06:38 +08:00
<!-- 试用音色面板 -->
2026-01-17 16:34:39 +08:00
<div class="voice-panel" id="panel-trialVoice">
<div class="card">
<div class="test-voice-box">
<div class="test-voice-row">
2026-01-18 03:06:38 +08:00
<input type="text" id="testTextTrial" class="input" value="咳~♪祝你生日快乐~" placeholder="输入测试文本">
<button class="btn btn-primary" id="testTrialVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
2026-01-17 16:34:39 +08:00
</div>
<div class="test-voice-status" id="testTrialStatus"></div>
</div>
<div class="voice-list" id="trialVoiceList"></div>
<div class="preset-save-row">
2026-01-18 03:06:38 +08:00
<input type="text" id="saveAsNameTrial" class="input" placeholder="保存名称(可选)">
<button class="btn btn-primary" id="saveToMyVoiceTrialBtn"><i class="fa-solid fa-plus"></i> 添加到我的音色</button>
2026-01-17 16:34:39 +08:00
</div>
</div>
</div>
2026-01-18 03:06:38 +08:00
<!-- 预设音色库面板 -->
2026-01-17 16:34:39 +08:00
<div class="voice-panel" id="panel-authVoice">
<div class="card">
<div id="authVoiceNotice" class="tip-box warning" style="margin-bottom: 16px;">
<i class="fa-solid fa-exclamation-triangle"></i>
2026-01-18 03:06:38 +08:00
<div>使用预设音色库需要先配置鉴权 API请前往「基础配置」页面设置。</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="test-voice-box">
<div class="test-voice-row">
2026-01-18 03:06:38 +08:00
<input type="text" id="testTextAuth" class="input" value="预备,唱:两只老虎,两只老虎..." placeholder="输入测试文本">
<button class="btn btn-primary" id="testAuthVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
2026-01-17 16:34:39 +08:00
</div>
<div class="test-voice-status" id="testAuthStatus"></div>
</div>
<div class="voice-search">
2026-01-18 03:06:38 +08:00
<input type="text" id="voiceSearchInput" class="input" placeholder="搜索音色名称...">
2026-01-17 16:34:39 +08:00
</div>
<div class="voice-filters">
<select id="voiceGenderFilter">
2026-01-18 03:06:38 +08:00
<option value="all">全部性别</option>
<option value="female">女声</option>
<option value="male">男声</option>
<option value="other">其他</option>
2026-01-17 16:34:39 +08:00
</select>
<select id="voiceModelFilter">
2026-01-18 03:06:38 +08:00
<option value="all">全部模型</option>
2026-01-17 16:34:39 +08:00
<option value="tts2">2.0</option>
<option value="tts1">1.0</option>
</select>
<select id="voiceLangFilter">
2026-01-18 03:06:38 +08:00
<option value="all">全部语种</option>
<option value="zh">中文</option>
<option value="en">英文</option>
<option value="other">日语、西班牙</option>
<option value="multi">多语</option>
2026-01-17 16:34:39 +08:00
</select>
<select id="voiceSceneFilter">
2026-01-18 03:06:38 +08:00
<option value="all">全部场景</option>
2026-01-17 16:34:39 +08:00
</select>
</div>
<div class="voice-list" id="authVoiceList"></div>
<div class="preset-save-row">
2026-01-18 03:06:38 +08:00
<input type="text" id="saveAsNameAuth" class="input" placeholder="保存名称(可选)">
<button class="btn btn-primary" id="saveToMyVoiceAuthBtn"><i class="fa-solid fa-plus"></i> 添加到我的音色</button>
2026-01-17 16:34:39 +08:00
</div>
</div>
</div>
2026-01-18 03:06:38 +08:00
<button class="btn btn-primary" id="saveVoiceBtn" style="margin-top: 8px;"><i class="fa-solid fa-floppy-disk"></i> 保存音色设置</button>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 03:06:38 +08:00
<!-- 高级设置 -->
2026-01-17 16:34:39 +08:00
<div id="view-advanced" class="view">
<div class="view-header">
2026-01-18 03:06:38 +08:00
<h2 class="view-title">高级设置</h2>
<p class="view-desc">计费、缓存与过滤选项(鉴权模式)</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">计费与缓存</div>
2026-01-17 16:34:39 +08:00
<div class="checkbox-row">
<input type="checkbox" id="usageReturn">
2026-01-18 03:06:38 +08:00
<label for="usageReturn">返回计费用量text_words</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="checkbox-row">
<input type="checkbox" id="serverCacheEnabled">
2026-01-18 03:06:38 +08:00
<label for="serverCacheEnabled">启用火山服务端缓存</label>
2026-01-17 16:34:39 +08:00
</div>
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">过滤与识别</div>
2026-01-17 16:34:39 +08:00
<div class="checkbox-row">
<input type="checkbox" id="disableMarkdownFilter">
2026-01-18 03:06:38 +08:00
<label for="disableMarkdownFilter">启用 Markdown 过滤</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="checkbox-row">
<input type="checkbox" id="useTts11" checked>
2026-01-18 03:06:38 +08:00
<label for="useTts11">启用 1.1 模型(仅对 seed-tts-1.0 生效)</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="checkbox-row">
<input type="checkbox" id="disableEmojiFilter">
2026-01-18 03:06:38 +08:00
<label for="disableEmojiFilter">不过滤 Emoji</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="checkbox-row">
<input type="checkbox" id="enableLanguageDetector">
2026-01-18 03:06:38 +08:00
<label for="enableLanguageDetector">启用自动语种识别</label>
2026-01-17 16:34:39 +08:00
</div>
<div class="form-group" style="margin-top: 12px;">
2026-01-18 03:06:38 +08:00
<label class="form-label">指定语种</label>
<input type="text" id="explicitLanguage" class="input" placeholder="如zh-cn / en / crosslingual">
2026-01-17 16:34:39 +08:00
</div>
<div class="form-row" style="margin-top: 12px;">
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">括号过滤长度</label>
2026-01-17 16:34:39 +08:00
<input type="number" id="maxLengthToFilterParenthesis" class="input" min="0" max="500">
</div>
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">音高调整</label>
2026-01-17 16:34:39 +08:00
<input type="number" id="postProcessPitch" class="input" min="-12" max="12">
</div>
</div>
</div>
2026-01-18 03:06:38 +08:00
<button class="btn btn-primary" id="saveAdvancedBtn"><i class="fa-solid fa-floppy-disk"></i> 保存高级设置</button>
2026-01-17 16:34:39 +08:00
</div>
2026-01-18 03:06:38 +08:00
<!-- 缓存管理 -->
2026-01-17 16:34:39 +08:00
<div id="view-cache" class="view">
<div class="view-header">
2026-01-18 03:06:38 +08:00
<h2 class="view-title">缓存管理</h2>
<p class="view-desc">本地音频缓存统计与清理</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="card">
<div class="stats-card">
<div class="stats-group">
<div class="stats-item">
<div class="stats-value" id="cacheCount">0</div>
2026-01-18 03:06:38 +08:00
<div class="stats-label">缓存条数</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="stats-item">
<div class="stats-value" id="cacheSize">0 MB</div>
2026-01-18 03:06:38 +08:00
<div class="stats-label">占用空间</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="stats-item">
<div class="stats-value" id="cacheHits">0</div>
2026-01-18 03:06:38 +08:00
<div class="stats-label">命中</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="stats-item">
<div class="stats-value" id="cacheMisses">0</div>
2026-01-18 03:06:38 +08:00
<div class="stats-label">未命中</div>
2026-01-17 16:34:39 +08:00
</div>
</div>
</div>
</div>
<div class="card">
2026-01-18 03:06:38 +08:00
<div class="card-title">缓存配置</div>
2026-01-17 16:34:39 +08:00
<div class="form-row">
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">缓存天数</label>
2026-01-17 16:34:39 +08:00
<input type="number" id="cacheDays" class="input" min="1" max="30">
</div>
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">最大条数</label>
2026-01-17 16:34:39 +08:00
<input type="number" id="cacheMaxEntries" class="input" min="10" max="5000">
</div>
<div class="form-group">
2026-01-18 03:06:38 +08:00
<label class="form-label">最大容量 (MB)</label>
2026-01-17 16:34:39 +08:00
<input type="number" id="cacheMaxMB" class="input" min="10" max="5000">
</div>
</div>
</div>
<div class="btn-group">
2026-01-18 03:06:38 +08:00
<button class="btn btn-primary" id="saveCacheBtn"><i class="fa-solid fa-floppy-disk"></i> 保存</button>
<button class="btn" id="cacheRefreshBtn"><i class="fa-solid fa-arrows-rotate"></i> 刷新</button>
<button class="btn" id="cacheClearExpiredBtn"><i class="fa-solid fa-broom"></i> 清理过期</button>
<button class="btn btn-danger" id="cacheClearAllBtn"><i class="fa-solid fa-trash"></i> 清空全部</button>
2026-01-17 16:34:39 +08:00
</div>
</div>
2026-01-18 03:06:38 +08:00
<!-- 使用说明 -->
2026-01-17 16:34:39 +08:00
<div id="view-guide" class="view">
<div class="view-header">
2026-01-18 03:06:38 +08:00
<h2 class="view-title">使用说明</h2>
<p class="view-desc">配音指令与开通流程</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-terminal"></i> 配音指令</h3>
<p>格式:<code>[tts:speaker=音色名;emotion=情绪;context=语气提示]</code> 放在正文前一行</p>
<p>speaker、emotion、context 三个参数可任意组合、任意顺序,用分号分隔</p>
<p>每遇到一个新 <code>[tts:...]</code> 块会分段朗读,按顺序播放</p>
<p>未写 <code>speaker=</code> 的块使用当前选中的默认音色</p>
2026-01-17 16:34:39 +08:00
2026-01-18 03:06:38 +08:00
<p style="margin-top: 12px;"><strong>音色speaker</strong></p>
<p>只能指定"我的音色"中保存的名称。例如保存了名为"小白"的音色,则可用 <code>speaker=小白</code></p>
2026-01-17 16:34:39 +08:00
2026-01-18 03:06:38 +08:00
<p style="margin-top: 12px;"><strong>情感emotion可用值</strong></p>
<pre>中文:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰、鼓励、咆哮、焦急、温柔、讲故事、自然讲述、情感电台、磁性、广告营销、气泡音、低语、新闻播报、娱乐八卦、方言、对话、闲聊、温暖、深情、权威
2026-01-18 02:55:49 +08:00
2026-01-18 03:06:38 +08:00
英文happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, radio, magnetic, advertising, vocal-fry, asmr, news, entertainment, dialect, chat, warm, affectionate, authoritative</pre>
2026-01-17 16:34:39 +08:00
2026-01-18 03:06:38 +08:00
<p style="margin-top: 12px;"><strong>语气提示context</strong>仅对 seed-tts-2.0 生效:</p>
<p>例如:"用更委屈的语气"、"放慢一点,压低音量"</p>
2026-01-17 16:34:39 +08:00
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-user-plus"></i> 复刻音色使用</h3>
2026-01-17 16:34:39 +08:00
<ol>
2026-01-18 03:06:38 +08:00
<li>在火山官网复刻音色</li>
<li>获取音色ID格式 <code>S_xxxxxxxx</code></li>
<li>在"音色管理" → "我的音色"中添加</li>
2026-01-17 16:34:39 +08:00
</ol>
</div>
<div class="tip-box warning" style="margin-bottom: 16px;">
<i class="fa-solid fa-exclamation-triangle"></i>
2026-01-18 03:06:38 +08:00
<div><strong>以下是鉴权模式的开通教程</strong>,试用音色无需配置。</div>
2026-01-17 16:34:39 +08:00
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-server"></i> 开启 CORS 代理</h3>
2026-01-17 16:34:39 +08:00
<ol>
2026-01-18 03:06:38 +08:00
<li>打开酒馆目录的 config.yaml</li>
<li>将 enableCorsProxy 改为 true 并保存</li>
<li>重启酒馆(重启容器/进程,不是 F5 刷新)</li>
2026-01-17 16:34:39 +08:00
</ol>
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-check-circle"></i> 开通服务(推荐一次性开通全部)</h3>
2026-01-17 16:34:39 +08:00
<div class="guide-link">
<a href="https://console.volcengine.com/speech/new/setting/activate" target="_blank">https://console.volcengine.com/speech/new/setting/activate</a>
</div>
2026-01-18 03:06:38 +08:00
<img class="guide-image" src="开通管理.png" alt="开通管理">
2026-01-17 16:34:39 +08:00
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-key"></i> 获取 Access Token / AppID</h3>
2026-01-17 16:34:39 +08:00
<div class="guide-link">
<a href="https://console.volcengine.com/speech/service/8" target="_blank">https://console.volcengine.com/speech/service/8</a>
</div>
2026-01-18 03:06:38 +08:00
<img class="guide-image" src="获取ID和KEY.png" alt="获取ID和KEY">
2026-01-17 16:34:39 +08:00
</div>
<div class="guide-box">
2026-01-18 03:06:38 +08:00
<h3><i class="fa-solid fa-microphone-lines"></i> 声音复刻入口(复刻后去音色库拿ID)</h3>
2026-01-17 16:34:39 +08:00
<div class="guide-link">
<a href="https://console.volcengine.com/speech/new/experience/clone" target="_blank">https://console.volcengine.com/speech/new/experience/clone</a>
</div>
2026-01-18 03:06:38 +08:00
<img class="guide-image" src="声音复刻.png" alt="声音复刻">
2026-01-17 16:34:39 +08:00
</div>
</div>
</main>
</div>
<nav class="mobile-nav">
<div class="mobile-nav-inner">
2026-01-18 03:06:38 +08:00
<div class="mobile-nav-item active" data-view="config"><i class="fa-solid fa-sliders"></i><span>配置</span></div>
<div class="mobile-nav-item" data-view="voice"><i class="fa-solid fa-user-astronaut"></i><span>音色</span></div>
<div class="mobile-nav-item" data-view="advanced"><i class="fa-solid fa-gear"></i><span>高级</span></div>
<div class="mobile-nav-item" data-view="cache"><i class="fa-solid fa-database"></i><span>缓存</span></div>
<div class="mobile-nav-item" data-view="guide"><i class="fa-solid fa-circle-question"></i><span>说明</span></div>
2026-01-17 16:34:39 +08:00
</div>
</nav>
</div>
<script src="tts-voices.js"></script>
<script>
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 常量与状态
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
const post = (type, payload) => parent.postMessage({ type, payload }, PARENT_ORIGIN);
let config = {};
let mySpeakers = [];
let selectedVoiceValue = '';
let selectedTrialVoiceValue = '';
let selectedAuthVoiceValue = '';
let editingVoiceValue = null;
let activeSaveBtn = null;
const TRIAL_VOICES = [
2026-01-18 03:06:38 +08:00
{ key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' },
{ key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' },
{ key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' },
{ key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' },
{ key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' },
{ key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' },
{ key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' },
{ key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' },
{ key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' },
{ key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' },
{ key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' },
2026-01-17 16:34:39 +08:00
];
const TRIAL_VOICE_KEYS = new Set(TRIAL_VOICES.map(v => v.key));
const AUTH_VOICE_DATA = Array.isArray(window.XB_TTS_VOICE_DATA) ? window.XB_TTS_VOICE_DATA : [];
const TTS2_VOICES = new Set((window.XB_TTS_TTS2_VOICE_INFO || []).map(item => item.value));
let authVoiceList = [];
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
const $ = id => document.getElementById(id);
const $$ = sel => document.querySelectorAll(sel);
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function switchView(viewId) {
$$('.view').forEach(v => v.classList.remove('active'));
$$('.nav-item, .mobile-nav-item').forEach(n => n.classList.remove('active'));
$(`view-${viewId}`)?.classList.add('active');
$$(`[data-view="${viewId}"]`).forEach(n => n.classList.add('active'));
}
function setSavingState(btn) {
if (!btn) return;
activeSaveBtn = btn;
const i = btn.querySelector('i');
if (i) { btn._origIcon = i.className; i.className = 'fa-solid fa-spinner fa-spin'; }
btn.classList.add('saving');
}
function handleSaveResult(success) {
if (!activeSaveBtn) return;
const btn = activeSaveBtn;
activeSaveBtn = null;
btn.classList.remove('saving');
const i = btn.querySelector('i');
if (success && i) {
i.className = 'fa-solid fa-check';
btn.classList.add('save-success');
setTimeout(() => { btn.classList.remove('save-success'); i.className = btn._origIcon || 'fa-solid fa-floppy-disk'; }, 1500);
} else if (i) {
i.className = btn._origIcon || 'fa-solid fa-floppy-disk';
}
}
function setTestStatus(elId, status, text) {
const el = $(elId);
if (!el) return;
el.textContent = text;
el.className = 'test-voice-status' + (status ? ' ' + status : '');
}
function getVoiceSource(value) {
if (!value) return 'free';
if (TRIAL_VOICE_KEYS.has(value)) return 'free';
return 'auth';
}
function isAuthConfigured() {
return !!(config?.volc?.appId && config?.volc?.accessKey);
}
function isInMyList(value) {
return mySpeakers.some(s => s.value === value);
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 规则编辑器
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function renderRulesList(listEl, rules, type) {
if (!rules?.length) {
2026-01-18 03:06:38 +08:00
listEl.innerHTML = `<div class="rules-empty">暂无规则</div>`;
2026-01-17 16:34:39 +08:00
return;
}
listEl.innerHTML = rules.map((rule, idx) => `
<div class="rule-item" data-idx="${idx}">
2026-01-18 03:06:38 +08:00
<input type="text" class="rule-input rule-start" value="${escapeHtml(rule.start || '')}" placeholder="起始(可为空)">
<span class="rule-arrow"></span>
<input type="text" class="rule-input rule-end" value="${escapeHtml(rule.end || '')}" placeholder="结束(可为空)">
2026-01-17 16:34:39 +08:00
<button class="rule-delete" data-type="${type}" data-idx="${idx}"><i class="fa-solid fa-times"></i></button>
</div>
`).join('');
}
function collectRules(listEl) {
const rules = [];
listEl.querySelectorAll('.rule-item').forEach(item => {
const start = item.querySelector('.rule-start')?.value?.trim() || '';
const end = item.querySelector('.rule-end')?.value?.trim() || '';
if (start || end) rules.push({ start, end });
});
return rules;
}
function initRulesEditors() {
$('addSkipRule').addEventListener('click', () => {
const rules = collectRules($('skipRulesList'));
rules.push({ start: '', end: '' });
renderRulesList($('skipRulesList'), rules, 'skip');
});
$('addReadRule').addEventListener('click', () => {
const rules = collectRules($('readRulesList'));
rules.push({ start: '', end: '' });
renderRulesList($('readRulesList'), rules, 'read');
});
document.addEventListener('click', e => {
const deleteBtn = e.target.closest('.rule-delete');
if (!deleteBtn) return;
const type = deleteBtn.dataset.type;
const idx = parseInt(deleteBtn.dataset.idx);
const listEl = type === 'skip' ? $('skipRulesList') : $('readRulesList');
const rules = collectRules(listEl);
rules.splice(idx, 1);
renderRulesList(listEl, rules, type);
});
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 音色处理
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function detectGenderByValue(value) {
const v = String(value || '').toLowerCase();
if (v.includes('female')) return 'female';
if (v.includes('male')) return 'male';
return 'other';
}
function detectModel(value) { return TTS2_VOICES.has(String(value).trim()) ? 'tts2' : 'tts1'; }
function detectTags(value, model) {
const tags = [];
if (model === 'tts2') tags.push('2.0');
if (model === 'tts1') tags.push('1.0');
2026-01-18 03:06:38 +08:00
if (String(value).includes('emo')) tags.push('多情感');
2026-01-17 16:34:39 +08:00
return tags;
}
function buildAuthVoiceList() {
authVoiceList = AUTH_VOICE_DATA.map(item => {
const value = item.value;
const name = item.name || value;
const gender = detectGenderByValue(value);
2026-01-18 03:06:38 +08:00
const genderLabel = gender === 'female' ? '女' : gender === 'male' ? '男' : '其他';
2026-01-17 16:34:39 +08:00
const model = detectModel(value);
const scene = String(item.scene || '').trim();
const tags = detectTags(value, model);
if (scene && !tags.includes(scene)) tags.push(scene);
2026-01-18 01:48:30 +08:00
let language;
if (model === 'tts2') {
language = 'multi';
} else if (value.startsWith('en_')) {
language = 'en';
} else if (value.startsWith('multi_')) {
language = 'other';
} else {
language = 'zh';
}
2026-01-17 16:34:39 +08:00
return { value, name, gender, genderLabel, model, language, scene, tags };
});
}
function formatTagLabel(tags) { return tags.length ? ` [${tags.join('/')}]` : ''; }
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// UI 更新
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function updateApiStatus() {
const configured = isAuthConfigured();
const box = $('apiStatusBox');
const icon = box.querySelector('.api-status-icon i');
const title = box.querySelector('.api-status-title');
const desc = box.querySelector('.api-status-desc');
const badge = $('badge_auth');
if (configured) {
box.className = 'api-status-box configured';
2026-01-18 01:48:30 +08:00
icon.className = 'fa-solid fa-link';
2026-01-18 03:06:38 +08:00
title.textContent = '已配置';
desc.textContent = '可使用预设音色库和复刻音色';
2026-01-18 01:48:30 +08:00
badge.className = 'header-badge active';
2026-01-17 16:34:39 +08:00
$('authVoiceNotice').style.display = 'none';
} else {
box.className = 'api-status-box not-configured';
2026-01-18 01:48:30 +08:00
icon.className = 'fa-solid fa-link-slash';
2026-01-18 03:06:38 +08:00
title.textContent = '未配置';
desc.textContent = '配置后可使用预设音色库和复刻音色';
2026-01-17 16:34:39 +08:00
badge.className = 'header-badge';
$('authVoiceNotice').style.display = 'flex';
}
}
function updateCurrentVoiceDisplay() {
const nameEl = $('currentVoiceName');
const sourceEl = $('currentVoiceSource');
if (!selectedVoiceValue) {
2026-01-18 03:06:38 +08:00
nameEl.innerHTML = '未选择';
sourceEl.textContent = '请在下方选择音色';
2026-01-17 16:34:39 +08:00
return;
}
const myVoice = mySpeakers.find(s => s.value === selectedVoiceValue);
const source = myVoice?.source || getVoiceSource(selectedVoiceValue);
const sourceBadge = source === 'free'
2026-01-18 03:06:38 +08:00
? '<span class="source-badge trial">试用</span>'
: '<span class="source-badge auth">鉴权</span>';
2026-01-17 16:34:39 +08:00
if (myVoice) {
nameEl.innerHTML = `${escapeHtml(myVoice.name)} ${sourceBadge}`;
2026-01-18 03:06:38 +08:00
sourceEl.textContent = '我的音色';
2026-01-17 16:34:39 +08:00
} else if (source === 'free') {
const tv = TRIAL_VOICES.find(v => v.key === selectedVoiceValue);
nameEl.innerHTML = `${escapeHtml(tv?.name || selectedVoiceValue)} ${sourceBadge}`;
2026-01-18 03:06:38 +08:00
sourceEl.textContent = tv?.tag || '试用音色';
2026-01-17 16:34:39 +08:00
} else {
const av = authVoiceList.find(v => v.value === selectedVoiceValue);
nameEl.innerHTML = `${escapeHtml(av?.name || selectedVoiceValue)} ${sourceBadge}`;
2026-01-18 03:06:38 +08:00
sourceEl.textContent = '预设音色(建议先添加到我的音色)';
2026-01-17 16:34:39 +08:00
}
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 渲染音色列表
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function renderMyVoiceList() {
const listEl = $('myVoiceList');
const emptyEl = $('myVoiceEmpty');
$('myVoiceCount').textContent = mySpeakers.length;
if (!mySpeakers.length) {
listEl.innerHTML = '';
listEl.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
listEl.style.display = 'flex';
emptyEl.style.display = 'none';
const authOk = isAuthConfigured();
listEl.innerHTML = mySpeakers.map(s => {
const isSelected = s.value === selectedVoiceValue;
const isEditing = s.value === editingVoiceValue;
const source = s.source || getVoiceSource(s.value);
const canPlay = source === 'free' || authOk;
const sourceBadge = source === 'free'
2026-01-18 03:06:38 +08:00
? '<span class="source-badge trial">试用</span>'
: '<span class="source-badge auth">鉴权</span>';
2026-01-17 16:34:39 +08:00
return `
<div class="voice-item${isSelected ? ' selected' : ''}${isEditing ? ' editing' : ''}${!canPlay ? ' disabled' : ''}"
data-value="${escapeHtml(s.value)}" data-source="${source}">
<div class="voice-item-radio"></div>
<div class="voice-item-info">
<div class="voice-item-name">${escapeHtml(s.name || s.value)} ${sourceBadge}</div>
2026-01-18 03:06:38 +08:00
<div class="voice-item-meta">${!canPlay ? '需配置鉴权' : escapeHtml(s.value.slice(0, 25))}</div>
2026-01-17 16:34:39 +08:00
<div class="voice-item-edit-form">
2026-01-18 03:06:38 +08:00
<input type="text" class="input voice-edit-input" value="${escapeHtml(s.name || '')}" placeholder="输入新名称">
2026-01-17 16:34:39 +08:00
<button class="btn btn-xs btn-primary voice-edit-save"><i class="fa-solid fa-check"></i></button>
<button class="btn btn-xs voice-edit-cancel"><i class="fa-solid fa-times"></i></button>
</div>
</div>
<div class="voice-item-actions">
2026-01-18 03:06:38 +08:00
<button class="btn btn-xs voice-rename-btn" data-value="${escapeHtml(s.value)}" title="改名">
2026-01-17 16:34:39 +08:00
<i class="fa-solid fa-pen"></i>
</button>
2026-01-18 03:06:38 +08:00
<button class="btn btn-xs btn-danger voice-delete-btn" data-value="${escapeHtml(s.value)}" title="删除">
2026-01-17 16:34:39 +08:00
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>`;
}).join('');
bindMyVoiceEvents(listEl);
}
function bindMyVoiceEvents(listEl) {
listEl.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', e => {
if (e.target.closest('button') || e.target.closest('input')) return;
if (item.classList.contains('disabled')) {
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'error', message: '请先配置鉴权 API' });
2026-01-17 16:34:39 +08:00
return;
}
if (editingVoiceValue) return;
selectedVoiceValue = item.dataset.value;
renderMyVoiceList();
updateCurrentVoiceDisplay();
});
});
listEl.querySelectorAll('.voice-rename-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
editingVoiceValue = btn.dataset.value;
renderMyVoiceList();
listEl.querySelector('.voice-edit-input')?.focus();
});
});
listEl.querySelectorAll('.voice-edit-save').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const item = mySpeakers.find(s => s.value === editingVoiceValue);
const input = btn.closest('.voice-item').querySelector('.voice-edit-input');
if (item && input?.value?.trim()) {
item.name = input.value.trim();
post('xb-tts:save-config', collectForm());
}
editingVoiceValue = null;
renderMyVoiceList();
updateCurrentVoiceDisplay();
});
});
listEl.querySelectorAll('.voice-edit-cancel').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
editingVoiceValue = null;
renderMyVoiceList();
});
});
listEl.querySelectorAll('.voice-delete-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const value = btn.dataset.value;
const item = mySpeakers.find(s => s.value === value);
2026-01-18 03:06:38 +08:00
if (confirm(`删除「${item?.name || value}」?`)) {
2026-01-17 16:34:39 +08:00
mySpeakers = mySpeakers.filter(s => s.value !== value);
if (selectedVoiceValue === value) {
selectedVoiceValue = mySpeakers[0]?.value || '';
}
renderMyVoiceList();
renderTrialVoiceList();
renderAuthVoiceList();
updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm());
}
});
});
}
function renderTrialVoiceList() {
const listEl = $('trialVoiceList');
listEl.innerHTML = TRIAL_VOICES.map(v => {
const isSelected = v.key === selectedTrialVoiceValue;
const inMy = isInMyList(v.key);
return `
<div class="voice-item${isSelected ? ' selected' : ''}${inMy ? ' in-my-list' : ''}" data-value="${v.key}">
<div class="voice-item-radio"></div>
<div class="voice-item-info">
2026-01-18 03:06:38 +08:00
<div class="voice-item-name">${escapeHtml(v.name)}${inMy ? ' <span class="source-badge trial">已添加</span>' : ''}</div>
<div class="voice-item-meta">${escapeHtml(v.tag)} · ${v.gender === 'female' ? '女' : '男'}</div>
2026-01-17 16:34:39 +08:00
</div>
</div>`;
}).join('');
listEl.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
selectedTrialVoiceValue = item.dataset.value;
renderTrialVoiceList();
});
});
}
function renderAuthVoiceList() {
const gender = $('voiceGenderFilter')?.value || 'all';
const model = $('voiceModelFilter')?.value || 'all';
const language = $('voiceLangFilter')?.value || 'all';
const scene = $('voiceSceneFilter')?.value || 'all';
const search = ($('voiceSearchInput')?.value || '').toLowerCase().trim();
const list = authVoiceList.filter(item => {
if (gender !== 'all' && item.gender !== gender) return false;
if (model !== 'all' && item.model !== model) return false;
if (language !== 'all' && item.language !== language) return false;
if (scene !== 'all' && item.scene !== scene) return false;
if (search && !item.name.toLowerCase().includes(search) && !item.value.toLowerCase().includes(search)) return false;
return true;
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
const listEl = $('authVoiceList');
if (!list.length) {
2026-01-18 03:06:38 +08:00
listEl.innerHTML = '<div class="rules-empty">没有匹配的音色</div>';
2026-01-17 16:34:39 +08:00
return;
}
listEl.innerHTML = list.map(item => {
const isSelected = item.value === selectedAuthVoiceValue;
const inMy = isInMyList(item.value);
return `
<div class="voice-item${isSelected ? ' selected' : ''}${inMy ? ' in-my-list' : ''}" data-value="${escapeHtml(item.value)}">
<div class="voice-item-radio"></div>
<div class="voice-item-info">
2026-01-18 03:06:38 +08:00
<div class="voice-item-name">${escapeHtml(item.name)}${inMy ? ' <span class="source-badge auth">已添加</span>' : ''}</div>
2026-01-17 16:34:39 +08:00
<div class="voice-item-meta">${escapeHtml(item.genderLabel)}${formatTagLabel(item.tags)}</div>
</div>
</div>`;
}).join('');
listEl.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
selectedAuthVoiceValue = item.dataset.value;
renderAuthVoiceList();
});
});
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 数据处理
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function normalizeMySpeakers(list) {
if (!Array.isArray(list)) return [];
return list.map(item => ({
name: String(item?.name || '').trim(),
value: String(item?.value || '').trim(),
source: item?.source || getVoiceSource(item?.value || ''),
2026-01-26 01:16:35 +08:00
resourceId: item?.resourceId || null,
2026-01-17 16:34:39 +08:00
})).filter(item => item.value);
}
function applyCacheStats(stats = {}) {
$('cacheCount').textContent = stats.count ?? 0;
$('cacheSize').textContent = `${stats.sizeMB ?? 0} MB`;
$('cacheHits').textContent = stats.hits ?? 0;
$('cacheMisses').textContent = stats.misses ?? 0;
}
function fillForm(cfg) {
config = cfg || {};
mySpeakers = normalizeMySpeakers(cfg.volc?.mySpeakers);
selectedVoiceValue = cfg.volc?.defaultSpeaker || '';
$('appId').value = cfg.volc?.appId || '';
$('accessKey').value = cfg.volc?.accessKey || '';
$('autoSpeak').checked = cfg.autoSpeak !== false;
const speechRate = Number.isFinite(cfg.volc?.speechRate) ? cfg.volc.speechRate : 1.0;
$('speechRate').value = speechRate;
$('speechRateValue').textContent = `${speechRate.toFixed(1)}x`;
renderRulesList($('skipRulesList'), cfg.skipRanges || [], 'skip');
renderRulesList($('readRulesList'), cfg.readRanges || [], 'read');
$('readRangesEnabled').checked = cfg.readRangesEnabled === true;
$('usageReturn').checked = cfg.volc?.usageReturn === true;
$('serverCacheEnabled').checked = cfg.volc?.serverCacheEnabled === true;
$('disableMarkdownFilter').checked = cfg.volc?.disableMarkdownFilter !== false;
$('useTts11').checked = cfg.volc?.useTts11 !== false;
$('disableEmojiFilter').checked = cfg.volc?.disableEmojiFilter === true;
$('enableLanguageDetector').checked = cfg.volc?.enableLanguageDetector === true;
$('explicitLanguage').value = cfg.volc?.explicitLanguage || '';
$('maxLengthToFilterParenthesis').value = cfg.volc?.maxLengthToFilterParenthesis ?? 100;
$('postProcessPitch').value = cfg.volc?.postProcessPitch ?? 0;
$('cacheDays').value = cfg.volc?.cacheDays ?? 7;
$('cacheMaxEntries').value = cfg.volc?.cacheMaxEntries ?? 200;
$('cacheMaxMB').value = cfg.volc?.cacheMaxMB ?? 200;
applyCacheStats(cfg.cacheStats || {});
2026-01-18 23:03:36 +08:00
$('showFloorButton').checked = cfg.showFloorButton !== false;
$('showFloatingButton').checked = cfg.showFloatingButton === true;
2026-01-17 16:34:39 +08:00
updateApiStatus();
renderMyVoiceList();
renderTrialVoiceList();
renderAuthVoiceList();
updateCurrentVoiceDisplay();
}
function inferResourceIdBySpeaker(value) {
const v = (value || '').trim().toLowerCase();
if (v.startsWith('icl_') || v.startsWith('s_')) return 'seed-icl-2.0';
if (TTS2_VOICES.has(value)) return 'seed-tts-2.0';
return 'seed-tts-1.0';
}
function collectForm() {
const speaker = selectedVoiceValue;
const source = getVoiceSource(speaker);
return {
volc: {
appId: $('appId').value.trim(),
accessKey: $('accessKey').value.trim(),
defaultResourceId: source === 'auth' ? inferResourceIdBySpeaker(speaker) : '',
defaultSpeaker: speaker,
mySpeakers: mySpeakers,
usageReturn: $('usageReturn').checked,
serverCacheEnabled: $('serverCacheEnabled').checked,
disableMarkdownFilter: $('disableMarkdownFilter').checked,
useTts11: $('useTts11').checked,
disableEmojiFilter: $('disableEmojiFilter').checked,
enableLanguageDetector: $('enableLanguageDetector').checked,
explicitLanguage: $('explicitLanguage').value.trim(),
maxLengthToFilterParenthesis: Number($('maxLengthToFilterParenthesis').value) || 0,
postProcessPitch: Number($('postProcessPitch').value) || 0,
speechRate: Number($('speechRate').value) || 1.0,
localCacheEnabled: true,
cacheDays: Math.max(1, Number($('cacheDays').value) || 7),
cacheMaxEntries: Math.max(10, Number($('cacheMaxEntries').value) || 200),
cacheMaxMB: Math.max(10, Number($('cacheMaxMB').value) || 200),
},
autoSpeak: $('autoSpeak').checked,
skipRanges: collectRules($('skipRulesList')),
readRanges: collectRules($('readRulesList')),
readRangesEnabled: $('readRangesEnabled').checked,
};
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 试听功能
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
function doTestVoice(speaker, source, textElId, statusElId) {
2026-01-18 03:06:38 +08:00
const text = $(textElId)?.value?.trim() || '你好,这是一段测试语音。';
2026-01-17 16:34:39 +08:00
if (!speaker) {
2026-01-18 03:06:38 +08:00
setTestStatus(statusElId, 'error', '请先选择一个音色');
2026-01-17 16:34:39 +08:00
return;
}
if (source === 'auth' && !isAuthConfigured()) {
2026-01-18 03:06:38 +08:00
setTestStatus(statusElId, 'error', '请先配置鉴权 API');
2026-01-17 16:34:39 +08:00
return;
}
2026-01-18 03:06:38 +08:00
setTestStatus(statusElId, 'playing', '正在合成...');
2026-01-17 16:34:39 +08:00
2026-01-26 01:16:35 +08:00
const speakerItem = mySpeakers.find(s => s.value === speaker);
const resolvedResourceId = speakerItem?.resourceId;
2026-01-17 16:34:39 +08:00
post('xb-tts:test-speak', {
text,
speaker,
source,
2026-01-26 01:16:35 +08:00
resourceId: source === 'auth' ? (resolvedResourceId || inferResourceIdBySpeaker(speaker)) : '',
2026-01-17 16:34:39 +08:00
});
}
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 消息处理
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
window.addEventListener('message', ev => {
if (ev.origin !== PARENT_ORIGIN || ev.source !== parent) return;
if (!ev.data?.type?.startsWith('xb-tts:')) return;
const { type, payload } = ev.data;
switch (type) {
case 'xb-tts:config':
fillForm(payload);
break;
case 'xb-tts:config-saved':
fillForm(payload);
handleSaveResult(true);
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'success', message: '配置已保存' });
2026-01-17 16:34:39 +08:00
break;
case 'xb-tts:config-save-error':
handleSaveResult(false);
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' });
2026-01-17 16:34:39 +08:00
break;
2026-01-18 23:03:36 +08:00
case 'xb-tts:button-mode-saved':
break;
2026-01-17 16:34:39 +08:00
case 'xb-tts:test-done':
2026-01-18 03:06:38 +08:00
['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, 'playing', '播放中...'));
2026-01-17 16:34:39 +08:00
setTimeout(() => ['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, '', '')), 3000);
break;
case 'xb-tts:test-error':
2026-01-18 03:06:38 +08:00
const errMsg = '失败: ' + (payload || '未知错误');
2026-01-17 16:34:39 +08:00
['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, 'error', errMsg));
break;
case 'xb-tts:cache-stats':
applyCacheStats(payload || {});
break;
}
});
2026-01-18 03:06:38 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════════════════
2026-01-18 02:55:49 +08:00
2026-01-17 16:34:39 +08:00
document.addEventListener('DOMContentLoaded', () => {
buildAuthVoiceList();
initRulesEditors();
const scenes = new Set();
authVoiceList.forEach(item => { if (item.scene) scenes.add(item.scene); });
2026-01-18 03:06:38 +08:00
$('voiceSceneFilter').innerHTML = '<option value="all">全部场景</option>' +
2026-01-17 16:34:39 +08:00
Array.from(scenes).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')).map(s => `<option value="${s}">${s}</option>`).join('');
$$('.nav-item, .mobile-nav-item').forEach(item => item.addEventListener('click', () => switchView(item.dataset.view)));
$('tts_close').addEventListener('click', () => post('xb-tts:close'));
2026-01-18 23:03:36 +08:00
$('showFloorButton').addEventListener('change', () => {
post('xb-tts:save-button-mode', {
showFloorButton: $('showFloorButton').checked,
showFloatingButton: $('showFloatingButton').checked
});
});
$('showFloatingButton').addEventListener('change', () => {
post('xb-tts:save-button-mode', {
showFloorButton: $('showFloorButton').checked,
showFloatingButton: $('showFloatingButton').checked
});
});
2026-01-17 16:34:39 +08:00
$('toggleKey').addEventListener('click', () => {
const input = $('accessKey');
const icon = $('toggleKey').querySelector('i');
input.type = input.type === 'password' ? 'text' : 'password';
icon.className = input.type === 'password' ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash';
});
['appId', 'accessKey'].forEach(id => {
$(id).addEventListener('input', () => {
config.volc = config.volc || {};
config.volc.appId = $('appId').value.trim();
config.volc.accessKey = $('accessKey').value.trim();
updateApiStatus();
renderMyVoiceList();
});
});
$('speechRate').addEventListener('input', e => {
$('speechRateValue').textContent = `${Number(e.target.value).toFixed(1)}x`;
});
['voiceGenderFilter', 'voiceModelFilter', 'voiceLangFilter', 'voiceSceneFilter'].forEach(id => {
$(id)?.addEventListener('change', renderAuthVoiceList);
});
$('voiceSearchInput')?.addEventListener('input', renderAuthVoiceList);
$$('.voice-tab').forEach(tab => {
tab.addEventListener('click', () => {
$$('.voice-tab').forEach(t => t.classList.remove('active'));
$$('.voice-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$(`panel-${tab.dataset.panel}`)?.classList.add('active');
});
});
$('testMyVoiceBtn').addEventListener('click', () => {
const my = mySpeakers.find(s => s.value === selectedVoiceValue);
const source = my?.source || getVoiceSource(selectedVoiceValue);
doTestVoice(selectedVoiceValue, source, 'testTextMy', 'testMyStatus');
});
$('testTrialVoiceBtn').addEventListener('click', () => {
doTestVoice(selectedTrialVoiceValue, 'free', 'testTextTrial', 'testTrialStatus');
});
$('testAuthVoiceBtn').addEventListener('click', () => {
doTestVoice(selectedAuthVoiceValue, 'auth', 'testTextAuth', 'testAuthStatus');
});
$('saveToMyVoiceTrialBtn').addEventListener('click', () => {
2026-01-18 03:06:38 +08:00
if (!selectedTrialVoiceValue) { post('xb-tts:toast', { type: 'error', message: '请先选择一个音色' }); return; }
2026-01-17 16:34:39 +08:00
const tv = TRIAL_VOICES.find(v => v.key === selectedTrialVoiceValue);
const name = $('saveAsNameTrial').value.trim() || tv?.name || selectedTrialVoiceValue;
if (!isInMyList(selectedTrialVoiceValue)) {
mySpeakers.push({ name, value: selectedTrialVoiceValue, source: 'free' });
}
selectedVoiceValue = selectedTrialVoiceValue;
$('saveAsNameTrial').value = '';
renderMyVoiceList();
renderTrialVoiceList();
updateCurrentVoiceDisplay();
$$('.voice-tab').forEach(t => t.classList.remove('active'));
$$('.voice-panel').forEach(p => p.classList.remove('active'));
$$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm());
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
2026-01-17 16:34:39 +08:00
});
$('saveToMyVoiceAuthBtn').addEventListener('click', () => {
2026-01-18 03:06:38 +08:00
if (!selectedAuthVoiceValue) { post('xb-tts:toast', { type: 'error', message: '请先选择一个音色' }); return; }
2026-01-17 16:34:39 +08:00
const av = authVoiceList.find(v => v.value === selectedAuthVoiceValue);
const name = $('saveAsNameAuth').value.trim() || av?.name || selectedAuthVoiceValue;
if (!isInMyList(selectedAuthVoiceValue)) {
mySpeakers.push({ name, value: selectedAuthVoiceValue, source: 'auth' });
}
selectedVoiceValue = selectedAuthVoiceValue;
$('saveAsNameAuth').value = '';
renderMyVoiceList();
renderAuthVoiceList();
updateCurrentVoiceDisplay();
$$('.voice-tab').forEach(t => t.classList.remove('active'));
$$('.voice-panel').forEach(p => p.classList.remove('active'));
$$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm());
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
2026-01-17 16:34:39 +08:00
});
$('addMySpeakerBtn').addEventListener('click', () => {
const id = $('newVoiceId').value.trim();
const name = $('newVoiceName').value.trim();
2026-01-26 01:16:35 +08:00
const resourceId = $('newVoiceResourceId').value;
2026-01-18 03:06:38 +08:00
if (!id) { post('xb-tts:toast', { type: 'error', message: '请输入音色ID' }); return; }
2026-01-17 16:34:39 +08:00
if (!isInMyList(id)) {
2026-01-26 01:16:35 +08:00
mySpeakers.push({ name: name || id, value: id, source: 'auth', resourceId });
2026-01-17 16:34:39 +08:00
}
selectedVoiceValue = id;
$('newVoiceId').value = '';
$('newVoiceName').value = '';
renderMyVoiceList();
updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm());
2026-01-18 03:06:38 +08:00
post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` });
2026-01-17 16:34:39 +08:00
});
['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => {
$(id)?.addEventListener('click', () => { setSavingState($(id)); post('xb-tts:save-config', collectForm()); });
});
$('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh'));
$('cacheClearExpiredBtn').addEventListener('click', () => post('xb-tts:cache-clear-expired'));
$('cacheClearAllBtn').addEventListener('click', () => post('xb-tts:cache-clear-all'));
post('xb-tts:ready');
});
</script>
</body>
2026-01-18 23:03:36 +08:00
</html>