Files
LittleWhiteBox/modules/tts/tts-overlay.html
2026-01-18 01:48:30 +08:00

2407 lines
93 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">
<title>TTS 语音设置</title>
<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; }
:root {
/* ═══════════════════════════════════════════════════════════════
极简科技风配色 - 黑白灰 + 单色点缀
═══════════════════════════════════════════════════════════════ */
/* 背景层次 */
--bg-primary: #0a0a0c;
--bg-secondary: #111114;
--bg-tertiary: #18181c;
--bg-elevated: #1e1e24;
--bg-input: rgba(255, 255, 255, 0.04);
/* 文字层次 */
--text-primary: #f0f0f2;
--text-secondary: #a0a0a8;
--text-muted: #606068;
--text-dim: #404048;
/* 边框 */
--border: rgba(255, 255, 255, 0.08);
--border-light: rgba(255, 255, 255, 0.12);
--border-focus: rgba(140, 200, 255, 0.4);
/* 唯一强调色 - 淡青蓝(科技感) */
--accent: #8cc8ff;
--accent-soft: rgba(140, 200, 255, 0.1);
--accent-glow: rgba(140, 200, 255, 0.15);
/* 功能色 - 极低饱和度 */
--success: #90d4a0;
--success-soft: rgba(144, 212, 160, 0.08);
--error: #e08080;
--error-soft: rgba(224, 128, 128, 0.08);
/* 试用/鉴权 - 不用彩色,用明暗区分 */
--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 */
--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;
min-height: 100dvh;
min-height: -webkit-fill-available;
}
/* ═══════════════════════════════════════════════════════════════
Header
═══════════════════════════════════════════════════════════════ */
.app-header {
display: flex;
align-items: center;
gap: 12px;
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;
}
.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;
}
.header-badge {
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;
}
.header-badge.active {
color: var(--text-secondary);
border-color: var(--border-light);
}
.header-badge.active i {
color: var(--accent);
opacity: 1;
}
.header-spacer { flex: 1; min-width: 10px; }
.header-close {
width: 36px; height: 36px; min-width: 36px;
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);
}
/* ═══════════════════════════════════════════════════════════════
Layout
═══════════════════════════════════════════════════════════════ */
.app-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.app-sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 16px 8px;
padding-left: calc(8px + var(--safe-area-left));
display: flex;
flex-direction: column;
gap: 4px;
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;
}
/* ═══════════════════════════════════════════════════════════════
Navigation
═══════════════════════════════════════════════════════════════ */
.nav-item {
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;
}
.nav-item i { width: 18px; text-align: center; }
.nav-divider {
height: 1px;
background: var(--border);
margin: 8px 0;
}
/* ═══════════════════════════════════════════════════════════════
Views
═══════════════════════════════════════════════════════════════ */
.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; }
}
.view-header { margin-bottom: 20px; }
.view-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
.view-desc {
font-size: 13px;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════
Cards
═══════════════════════════════════════════════════════════════ */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.card-title {
font-size: 11px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ═══════════════════════════════════════════════════════════════
Forms
═══════════════════════════════════════════════════════════════ */
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
.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;
}
.input {
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;
}
select.input { cursor: pointer; }
.input-row { display: flex; gap: 8px; }
.input-row .input { flex: 1; min-width: 0; }
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.checkbox-row label {
font-size: 13px;
cursor: pointer;
color: var(--text-secondary);
}
/* ═══════════════════════════════════════════════════════════════
Buttons
═══════════════════════════════════════════════════════════════ */
.btn {
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);
}
.btn:active { transform: scale(0.98); }
.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);
}
.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; }
.btn.save-success {
background: var(--success-soft) !important;
border-color: rgba(144, 212, 160, 0.3) !important;
color: var(--success) !important;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
Slider
═══════════════════════════════════════════════════════════════ */
.slider-row { display: flex; align-items: center; gap: 12px; }
.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;
}
/* ═══════════════════════════════════════════════════════════════
Rules Editor
═══════════════════════════════════════════════════════════════ */
.rules-editor {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.rules-header-title {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rules-list { max-height: 200px; overflow-y: auto; }
.rules-empty {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-size: 12px;
}
.rule-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.rule-item:last-child { border-bottom: none; }
.rule-item:hover { background: rgba(255,255,255,0.02); }
.rule-input {
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;
}
.rule-input:focus {
outline: none;
border-color: var(--border-focus);
}
.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);
}
/* ═══════════════════════════════════════════════════════════════
Current Voice Card - 极简版
═══════════════════════════════════════════════════════════════ */
.current-voice-card {
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;
}
.current-voice-icon {
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);
}
.current-voice-info { flex: 1; }
.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;
}
/* ═══════════════════════════════════════════════════════════════
Source Badge - 极简黑白版
═══════════════════════════════════════════════════════════════ */
.source-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* 试用 - 白色/浅色调 */
.source-badge.trial {
background: var(--tag-free-bg);
color: var(--tag-free);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 鉴权 - 蓝色点缀 */
.source-badge.auth {
background: var(--tag-auth-bg);
color: var(--tag-auth);
border: 1px solid rgba(140, 200, 255, 0.15);
}
/* ═══════════════════════════════════════════════════════════════
Voice Tabs - 极简版
═══════════════════════════════════════════════════════════════ */
.voice-tabs {
display: flex;
gap: 2px;
margin-bottom: 16px;
background: var(--bg-tertiary);
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border);
}
.voice-tab {
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;
}
.voice-tab-count {
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);
}
.voice-panel { display: none; }
.voice-panel.active { display: block; }
/* ═══════════════════════════════════════════════════════════════
Test Voice Box
═══════════════════════════════════════════════════════════════ */
.test-voice-box {
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;
}
.test-voice-row .input { flex: 1; }
.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); }
/* ═══════════════════════════════════════════════════════════════
Voice Filters
═══════════════════════════════════════════════════════════════ */
.voice-filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.voice-filters select {
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;
}
.voice-search .input { flex: 1; }
/* ═══════════════════════════════════════════════════════════════
Voice List - 极简版
═══════════════════════════════════════════════════════════════ */
.voice-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.voice-item {
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);
}
.voice-item-radio {
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);
}
.voice-item.selected .voice-item-radio {
border-color: var(--accent);
background: var(--accent);
}
.voice-item.selected .voice-item-radio::after {
content: '✓';
color: var(--bg-primary);
font-size: 9px;
font-weight: bold;
}
.voice-item-info { flex: 1; min-width: 0; }
.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;
}
/* ═══════════════════════════════════════════════════════════════
API Status Box
═══════════════════════════════════════════════════════════════ */
.api-status-box {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 16px;
}
.api-status-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
background: var(--bg-elevated);
border: 1px solid var(--border);
}
.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);
}
.api-status-info { flex: 1; }
.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;
}
/* ═══════════════════════════════════════════════════════════════
Stats Card
═══════════════════════════════════════════════════════════════ */
.stats-card {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.stats-group {
display: flex;
gap: 32px;
}
.stats-item { text-align: center; }
.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;
}
/* ═══════════════════════════════════════════════════════════════
Tip Box - 极简版
═══════════════════════════════════════════════════════════════ */
.tip-box {
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);
}
/* ═══════════════════════════════════════════════════════════════
Guide Box
═══════════════════════════════════════════════════════════════ */
.guide-box {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.guide-box h3 {
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;
}
.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;
}
.guide-box a { color: var(--accent); }
.guide-box code {
background: var(--bg-input);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: var(--accent);
font-family: monospace;
border: 1px solid var(--border);
}
.guide-box pre {
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;
border: 1px solid var(--border);
}
.guide-image {
margin-top: 12px;
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 8px;
display: block;
opacity: 0.9;
}
.guide-image:hover { opacity: 1; }
.guide-link {
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;
}
.guide-link a { color: var(--accent); }
/* ═══════════════════════════════════════════════════════════════
Mobile Navigation
═══════════════════════════════════════════════════════════════ */
.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;
}
.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; }
.mobile-nav-item {
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;
}
.mobile-nav-item i { font-size: 18px; }
.mobile-nav-item:hover { color: var(--text-muted); }
.mobile-nav-item.active { color: var(--accent); }
/* ═══════════════════════════════════════════════════════════════
Responsive
═══════════════════════════════════════════════════════════════ */
@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));
}
.view { padding-bottom: 20px; }
.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; }
}
/* ═══════════════════════════════════════════════════════════════
Scrollbar
═══════════════════════════════════════════════════════════════ */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.15);
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="app-container">
<header class="app-header">
<div class="header-logo"><i class="fa-solid fa-microphone"></i><span>TTS 语音</span></div>
<div class="header-status">
<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>
</div>
<div class="header-spacer"></div>
<button id="tts_close" class="header-close"></button>
</header>
<div class="app-body">
<nav class="app-sidebar">
<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>
<div class="nav-divider"></div>
<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>
<div class="nav-divider"></div>
<div class="nav-item" data-view="guide"><i class="fa-solid fa-circle-question"></i>使用说明</div>
</nav>
<main class="app-main">
<!-- 基础配置 -->
<div id="view-config" class="view active">
<div class="view-header">
<h2 class="view-title">基础配置</h2>
<p class="view-desc">TTS 服务连接与朗读设置</p>
</div>
<div class="tip-box" style="margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i>
<div>
<strong>试用音色</strong> — 无需配置使用插件服务器11个音色<br>
<strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻)
</div>
</div>
<div class="card">
<div class="card-title">鉴权配置(可选)</div>
<div id="apiStatusBox" class="api-status-box not-configured">
<div class="api-status-icon"><i class="fa-solid fa-link-slash"></i></div>
<div class="api-status-info">
<div class="api-status-title">未配置</div>
<div class="api-status-desc">配置后可使用预设音色库和复刻音色</div>
</div>
</div>
<div class="form-group">
<label class="form-label">AppID</label>
<input type="text" id="appId" class="input" placeholder="火山引擎 AppID">
</div>
<div class="form-group">
<label class="form-label">Access Token</label>
<div class="input-row">
<input type="password" id="accessKey" class="input" placeholder="火山引擎 Access Token">
<button id="toggleKey" class="btn btn-icon"><i class="fa-solid fa-eye"></i></button>
</div>
<p class="form-hint">获取方式见「使用说明」页</p>
</div>
</div>
<div class="card">
<div class="card-title">朗读设置</div>
<div class="checkbox-row">
<input type="checkbox" id="autoSpeak" checked>
<label for="autoSpeak">AI 回复后自动朗读</label>
</div>
<div class="form-group">
<label class="form-label">语速</label>
<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">
<div class="card-title">文本过滤</div>
<div class="form-group">
<label class="form-label">跳过区间</label>
<p class="form-hint" style="margin-bottom: 8px;">遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。</p>
<div class="rules-editor">
<div class="rules-header">
<span class="rules-header-title">当前规则</span>
<button class="btn btn-sm" id="addSkipRule"><i class="fa-solid fa-plus"></i> 添加</button>
</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">
<label for="readRangesEnabled">启用只读区间(仅朗读匹配内容)</label>
</div>
<p class="form-hint" style="margin-bottom: 8px;">起始或结束可单独留空。</p>
<div class="rules-editor">
<div class="rules-header">
<span class="rules-header-title">只读规则</span>
<button class="btn btn-sm" id="addReadRule"><i class="fa-solid fa-plus"></i> 添加</button>
</div>
<div class="rules-list" id="readRulesList"></div>
</div>
</div>
</div>
<button id="saveConfigBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> 保存配置</button>
</div>
<!-- 音色管理 -->
<div id="view-voice" class="view">
<div class="view-header">
<h2 class="view-title">音色管理</h2>
<p class="view-desc">将喜欢的音色重命名加入【我的音色】</p>
</div>
<div class="current-voice-card" id="currentVoiceCard">
<div class="current-voice-label">当前默认音色</div>
<div class="current-voice-display">
<div class="current-voice-icon"><i class="fa-solid fa-microphone-lines"></i></div>
<div class="current-voice-info">
<div class="current-voice-name" id="currentVoiceName">未选择</div>
<div class="current-voice-source" id="currentVoiceSource">请在下方选择音色</div>
</div>
</div>
</div>
<div class="voice-tabs">
<button class="voice-tab active" data-panel="myVoice">
<i class="fa-solid fa-star"></i> 我的
<span class="voice-tab-count" id="myVoiceCount">0</span>
</button>
<button class="voice-tab" data-panel="trialVoice">
<i class="fa-solid fa-flask"></i> 试用
</button>
<button class="voice-tab" data-panel="authVoice">
<i class="fa-solid fa-list"></i> 预设库
</button>
</div>
<!-- 我的音色面板 -->
<div class="voice-panel active" id="panel-myVoice">
<div class="card">
<div class="test-voice-box">
<div class="test-voice-row">
<input type="text" id="testTextMy" class="input" value="哼唱:...我能想到最浪漫的事" placeholder="输入测试文本">
<button class="btn btn-primary" id="testMyVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
</div>
<div class="test-voice-status" id="testMyStatus"></div>
</div>
<p class="form-hint" style="margin-bottom: 12px;">
点击选中设为默认。<span class="source-badge trial">试用</span> 无需配置,<span class="source-badge auth">鉴权</span> 需配置 API
</p>
<div class="voice-list" id="myVoiceList"></div>
<div id="myVoiceEmpty" class="rules-empty">
<i class="fa-solid fa-inbox" style="font-size: 24px; margin-bottom: 8px; display: block; opacity: 0.5;"></i>
暂无音色,请从「试用」或「预设库」添加
</div>
<div class="voice-add-form">
<div class="form-label" style="margin-bottom: 8px;">手动添加复刻音色 <span class="source-badge auth">鉴权</span></div>
<div class="voice-add-row">
<div class="form-group">
<label class="form-label">音色 ID</label>
<input type="text" id="newVoiceId" class="input" placeholder="如 S_xxx">
</div>
<div class="form-group">
<label class="form-label">名称</label>
<input type="text" id="newVoiceName" class="input" placeholder="显示名称">
</div>
<button class="btn btn-primary" id="addMySpeakerBtn" style="margin-top: 18px;"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
</div>
</div>
<!-- 试用音色面板 -->
<div class="voice-panel" id="panel-trialVoice">
<div class="card">
<div class="test-voice-box">
<div class="test-voice-row">
<input type="text" id="testTextTrial" class="input" value="哼唱:...我能想到最浪漫的事" placeholder="输入测试文本">
<button class="btn btn-primary" id="testTrialVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
</div>
<div class="test-voice-status" id="testTrialStatus"></div>
</div>
<div class="voice-list" id="trialVoiceList"></div>
<div class="preset-save-row">
<input type="text" id="saveAsNameTrial" class="input" placeholder="保存名称(可选)">
<button class="btn btn-primary" id="saveToMyVoiceTrialBtn"><i class="fa-solid fa-plus"></i> 添加到我的音色</button>
</div>
</div>
</div>
<!-- 预设音色库面板 -->
<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>
<div>使用预设音色库需要先配置鉴权 API请前往「基础配置」页面设置。</div>
</div>
<div class="test-voice-box">
<div class="test-voice-row">
<input type="text" id="testTextAuth" class="input" value="哼唱:...我能想到最浪漫的事" placeholder="输入测试文本">
<button class="btn btn-primary" id="testAuthVoiceBtn"><i class="fa-solid fa-play"></i> 试听</button>
</div>
<div class="test-voice-status" id="testAuthStatus"></div>
</div>
<div class="voice-search">
<input type="text" id="voiceSearchInput" class="input" placeholder="搜索音色名称...">
</div>
<div class="voice-filters">
<select id="voiceGenderFilter">
<option value="all">全部性别</option>
<option value="female">女声</option>
<option value="male">男声</option>
<option value="other">其他</option>
</select>
<select id="voiceModelFilter">
<option value="all">全部模型</option>
<option value="tts2">2.0</option>
<option value="tts1">1.0</option>
</select>
<select id="voiceLangFilter">
<option value="all">全部语种</option>
<option value="zh">中文</option>
<option value="en">英文</option>
<option value="other">日语、西班牙</option>
<option value="multi">多语</option>
</select>
<select id="voiceSceneFilter">
<option value="all">全部场景</option>
</select>
</div>
<div class="voice-list" id="authVoiceList"></div>
<div class="preset-save-row">
<input type="text" id="saveAsNameAuth" class="input" placeholder="保存名称(可选)">
<button class="btn btn-primary" id="saveToMyVoiceAuthBtn"><i class="fa-solid fa-plus"></i> 添加到我的音色</button>
</div>
</div>
</div>
<button class="btn btn-primary" id="saveVoiceBtn" style="margin-top: 8px;"><i class="fa-solid fa-floppy-disk"></i> 保存音色设置</button>
</div>
<!-- 高级设置 -->
<div id="view-advanced" class="view">
<div class="view-header">
<h2 class="view-title">高级设置</h2>
<p class="view-desc">计费、缓存与过滤选项(鉴权模式)</p>
</div>
<div class="card">
<div class="card-title">计费与缓存</div>
<div class="checkbox-row">
<input type="checkbox" id="usageReturn">
<label for="usageReturn">返回计费用量text_words</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="serverCacheEnabled">
<label for="serverCacheEnabled">启用火山服务端缓存</label>
</div>
</div>
<div class="card">
<div class="card-title">过滤与识别</div>
<div class="checkbox-row">
<input type="checkbox" id="disableMarkdownFilter">
<label for="disableMarkdownFilter">启用 Markdown 过滤</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="useTts11" checked>
<label for="useTts11">启用 1.1 模型(仅对 seed-tts-1.0 生效)</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="disableEmojiFilter">
<label for="disableEmojiFilter">不过滤 Emoji</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="enableLanguageDetector">
<label for="enableLanguageDetector">启用自动语种识别</label>
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="form-label">指定语种</label>
<input type="text" id="explicitLanguage" class="input" placeholder="如zh-cn / en / crosslingual">
</div>
<div class="form-row" style="margin-top: 12px;">
<div class="form-group">
<label class="form-label">括号过滤长度</label>
<input type="number" id="maxLengthToFilterParenthesis" class="input" min="0" max="500">
</div>
<div class="form-group">
<label class="form-label">音高调整</label>
<input type="number" id="postProcessPitch" class="input" min="-12" max="12">
</div>
</div>
</div>
<button class="btn btn-primary" id="saveAdvancedBtn"><i class="fa-solid fa-floppy-disk"></i> 保存高级设置</button>
</div>
<!-- 缓存管理 -->
<div id="view-cache" class="view">
<div class="view-header">
<h2 class="view-title">缓存管理</h2>
<p class="view-desc">本地音频缓存统计与清理</p>
</div>
<div class="card">
<div class="stats-card">
<div class="stats-group">
<div class="stats-item">
<div class="stats-value" id="cacheCount">0</div>
<div class="stats-label">缓存条数</div>
</div>
<div class="stats-item">
<div class="stats-value" id="cacheSize">0 MB</div>
<div class="stats-label">占用空间</div>
</div>
<div class="stats-item">
<div class="stats-value" id="cacheHits">0</div>
<div class="stats-label">命中</div>
</div>
<div class="stats-item">
<div class="stats-value" id="cacheMisses">0</div>
<div class="stats-label">未命中</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">缓存配置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">缓存天数</label>
<input type="number" id="cacheDays" class="input" min="1" max="30">
</div>
<div class="form-group">
<label class="form-label">最大条数</label>
<input type="number" id="cacheMaxEntries" class="input" min="10" max="5000">
</div>
<div class="form-group">
<label class="form-label">最大容量 (MB)</label>
<input type="number" id="cacheMaxMB" class="input" min="10" max="5000">
</div>
</div>
</div>
<div class="btn-group">
<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>
</div>
</div>
<!-- 使用说明 -->
<div id="view-guide" class="view">
<div class="view-header">
<h2 class="view-title">使用说明</h2>
<p class="view-desc">配音指令与开通流程</p>
</div>
<div class="guide-box">
<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>
<p style="margin-top: 12px;"><strong>音色speaker</strong></p>
<p>只能指定"我的音色"中保存的名称。例如保存了名为"小白"的音色,则可用 <code>speaker=小白</code></p>
<p style="margin-top: 12px;"><strong>情感emotion可用值</strong></p>
<pre>中文:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰、鼓励、咆哮、焦急、温柔、讲故事、自然讲述、情感电台、磁性、广告营销、气泡音、低语、新闻播报、娱乐八卦、方言、对话、闲聊、温暖、深情、权威
英文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>
<p style="margin-top: 12px;"><strong>语气提示context</strong>仅对 seed-tts-2.0 生效:</p>
<p>例如:"用更委屈的语气"、"放慢一点,压低音量"</p>
</div>
<div class="guide-box">
<h3><i class="fa-solid fa-user-plus"></i> 复刻音色使用</h3>
<ol>
<li>在火山官网复刻音色</li>
<li>获取音色ID格式 <code>S_xxxxxxxx</code></li>
<li>在"音色管理" → "我的音色"中添加</li>
</ol>
</div>
<div class="tip-box warning" style="margin-bottom: 16px;">
<i class="fa-solid fa-exclamation-triangle"></i>
<div><strong>以下是鉴权模式的开通教程</strong>,试用音色无需配置。</div>
</div>
<div class="guide-box">
<h3><i class="fa-solid fa-server"></i> 开启 CORS 代理</h3>
<ol>
<li>打开酒馆目录的 config.yaml</li>
<li>将 enableCorsProxy 改为 true 并保存</li>
<li>重启酒馆(重启容器/进程,不是 F5 刷新)</li>
</ol>
</div>
<div class="guide-box">
<h3><i class="fa-solid fa-check-circle"></i> 开通服务(推荐一次性开通全部)</h3>
<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>
<img class="guide-image" src="开通管理.png" alt="开通管理">
</div>
<div class="guide-box">
<h3><i class="fa-solid fa-key"></i> 获取 Access Token / AppID</h3>
<div class="guide-link">
<a href="https://console.volcengine.com/speech/service/8" target="_blank">https://console.volcengine.com/speech/service/8</a>
</div>
<img class="guide-image" src="获取ID和KEY.png" alt="获取ID和KEY">
</div>
<div class="guide-box">
<h3><i class="fa-solid fa-microphone-lines"></i> 声音复刻入口(复刻后去音色库拿ID)</h3>
<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>
<img class="guide-image" src="声音复刻.png" alt="声音复刻">
</div>
</div>
</main>
</div>
<nav class="mobile-nav">
<div class="mobile-nav-inner">
<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>
</div>
</nav>
</div>
<script src="tts-voices.js"></script>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// 常量与状态
// ═══════════════════════════════════════════════════════════════════════════
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 = [
{ 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' },
];
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 = [];
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
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);
}
// ═══════════════════════════════════════════════════════════════════════════
// 规则编辑器
// ═══════════════════════════════════════════════════════════════════════════
function renderRulesList(listEl, rules, type) {
if (!rules?.length) {
listEl.innerHTML = `<div class="rules-empty">暂无规则</div>`;
return;
}
listEl.innerHTML = rules.map((rule, idx) => `
<div class="rule-item" data-idx="${idx}">
<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="结束(可为空)">
<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);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 音色处理
// ═══════════════════════════════════════════════════════════════════════════
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');
if (String(value).includes('emo')) tags.push('多情感');
return tags;
}
function buildAuthVoiceList() {
authVoiceList = AUTH_VOICE_DATA.map(item => {
const value = item.value;
const name = item.name || value;
const gender = detectGenderByValue(value);
const genderLabel = gender === 'female' ? '女' : gender === 'male' ? '男' : '其他';
const model = detectModel(value);
const scene = String(item.scene || '').trim();
const tags = detectTags(value, model);
if (scene && !tags.includes(scene)) tags.push(scene);
let language;
if (model === 'tts2') {
language = 'multi';
} else if (value.startsWith('en_')) {
language = 'en';
} else if (value.startsWith('multi_')) {
language = 'other';
} else {
language = 'zh';
}
return { value, name, gender, genderLabel, model, language, scene, tags };
});
}
function formatTagLabel(tags) { return tags.length ? ` [${tags.join('/')}]` : ''; }
// ═══════════════════════════════════════════════════════════════════════════
// UI 更新
// ═══════════════════════════════════════════════════════════════════════════
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';
icon.className = 'fa-solid fa-link';
title.textContent = '已配置';
desc.textContent = '可使用预设音色库和复刻音色';
badge.className = 'header-badge active';
$('authVoiceNotice').style.display = 'none';
} else {
box.className = 'api-status-box not-configured';
icon.className = 'fa-solid fa-link-slash';
title.textContent = '未配置';
desc.textContent = '配置后可使用预设音色库和复刻音色';
badge.className = 'header-badge';
$('authVoiceNotice').style.display = 'flex';
}
}
function updateCurrentVoiceDisplay() {
const nameEl = $('currentVoiceName');
const sourceEl = $('currentVoiceSource');
if (!selectedVoiceValue) {
nameEl.innerHTML = '未选择';
sourceEl.textContent = '请在下方选择音色';
return;
}
const myVoice = mySpeakers.find(s => s.value === selectedVoiceValue);
const source = myVoice?.source || getVoiceSource(selectedVoiceValue);
const sourceBadge = source === 'free'
? '<span class="source-badge trial">试用</span>'
: '<span class="source-badge auth">鉴权</span>';
if (myVoice) {
nameEl.innerHTML = `${escapeHtml(myVoice.name)} ${sourceBadge}`;
sourceEl.textContent = '我的音色';
} else if (source === 'free') {
const tv = TRIAL_VOICES.find(v => v.key === selectedVoiceValue);
nameEl.innerHTML = `${escapeHtml(tv?.name || selectedVoiceValue)} ${sourceBadge}`;
sourceEl.textContent = tv?.tag || '试用音色';
} else {
const av = authVoiceList.find(v => v.value === selectedVoiceValue);
nameEl.innerHTML = `${escapeHtml(av?.name || selectedVoiceValue)} ${sourceBadge}`;
sourceEl.textContent = '预设音色(建议先添加到我的音色)';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 渲染音色列表
// ═══════════════════════════════════════════════════════════════════════════
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'
? '<span class="source-badge trial">试用</span>'
: '<span class="source-badge auth">鉴权</span>';
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>
<div class="voice-item-meta">${!canPlay ? '需配置鉴权' : escapeHtml(s.value.slice(0, 25))}</div>
<div class="voice-item-edit-form">
<input type="text" class="input voice-edit-input" value="${escapeHtml(s.name || '')}" placeholder="输入新名称">
<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">
<button class="btn btn-xs voice-rename-btn" data-value="${escapeHtml(s.value)}" title="改名">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-xs btn-danger voice-delete-btn" data-value="${escapeHtml(s.value)}" title="删除">
<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')) {
post('xb-tts:toast', { type: 'error', message: '请先配置鉴权 API' });
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);
if (confirm(`删除「${item?.name || value}」?`)) {
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">
<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>
</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) {
listEl.innerHTML = '<div class="rules-empty">没有匹配的音色</div>';
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">
<div class="voice-item-name">${escapeHtml(item.name)}${inMy ? ' <span class="source-badge auth">已添加</span>' : ''}</div>
<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();
});
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 数据处理
// ═══════════════════════════════════════════════════════════════════════════
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 || ''),
})).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 || {});
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,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 试听功能
// ═══════════════════════════════════════════════════════════════════════════
function doTestVoice(speaker, source, textElId, statusElId) {
const text = $(textElId)?.value?.trim() || '你好,这是一段测试语音。';
if (!speaker) {
setTestStatus(statusElId, 'error', '请先选择一个音色');
return;
}
if (source === 'auth' && !isAuthConfigured()) {
setTestStatus(statusElId, 'error', '请先配置鉴权 API');
return;
}
setTestStatus(statusElId, 'playing', '正在合成...');
post('xb-tts:test-speak', {
text,
speaker,
source,
resourceId: source === 'auth' ? inferResourceIdBySpeaker(speaker) : '',
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 消息处理
// ═══════════════════════════════════════════════════════════════════════════
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);
post('xb-tts:toast', { type: 'success', message: '配置已保存' });
break;
case 'xb-tts:config-save-error':
handleSaveResult(false);
post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' });
break;
case 'xb-tts:test-done':
['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, 'playing', '播放中...'));
setTimeout(() => ['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, '', '')), 3000);
break;
case 'xb-tts:test-error':
const errMsg = '失败: ' + (payload || '未知错误');
['testMyStatus', 'testTrialStatus', 'testAuthStatus'].forEach(id => setTestStatus(id, 'error', errMsg));
break;
case 'xb-tts:cache-stats':
applyCacheStats(payload || {});
break;
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
buildAuthVoiceList();
initRulesEditors();
const scenes = new Set();
authVoiceList.forEach(item => { if (item.scene) scenes.add(item.scene); });
$('voiceSceneFilter').innerHTML = '<option value="all">全部场景</option>' +
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'));
$('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', () => {
if (!selectedTrialVoiceValue) { post('xb-tts:toast', { type: 'error', message: '请先选择一个音色' }); return; }
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());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
});
$('saveToMyVoiceAuthBtn').addEventListener('click', () => {
if (!selectedAuthVoiceValue) { post('xb-tts:toast', { type: 'error', message: '请先选择一个音色' }); return; }
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());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
});
$('addMySpeakerBtn').addEventListener('click', () => {
const id = $('newVoiceId').value.trim();
const name = $('newVoiceName').value.trim();
if (!id) { post('xb-tts:toast', { type: 'error', message: '请输入音色ID' }); return; }
if (!isInMyList(id)) {
mySpeakers.push({ name: name || id, value: id, source: 'auth' });
}
selectedVoiceValue = id;
$('newVoiceId').value = '';
$('newVoiceName').value = '';
renderMyVoiceList();
updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` });
});
['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>
</html>