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

1751 lines
84 KiB
HTML
Raw 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">
<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: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-input: rgba(0, 0, 0, 0.25);
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--border: rgba(255, 255, 255, 0.1);
--border-focus: rgba(96, 165, 250, 0.5);
--accent: #60a5fa;
--accent-soft: rgba(96, 165, 250, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--danger: #f85149;
--warning: #d29922;
--trial-color: #a78bfa;
--trial-soft: rgba(167, 139, 250, 0.15);
--auth-color: #f59e0b;
--auth-soft: rgba(245, 158, 11, 0.15);
/* 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;
}
.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; }
.header-logo i { color: var(--accent); }
.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);
}
.header-badge.auth { color: var(--auth-color); border-color: rgba(245, 158, 11, 0.3); }
.header-badge.trial { color: var(--trial-color); border-color: rgba(167, 139, 250, 0.3); }
.header-badge i { font-size: 6px; }
.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-secondary);
cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px;
}
.header-close:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); }
.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;
}
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-radius: 8px; color: var(--text-secondary); cursor: pointer;
transition: all 0.15s; font-size: 13px;
}
.nav-item:hover { background: rgba(255,255,255,0.04); color: var(--text-primary); }
.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; }
.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; }
.view-desc { font-size: 13px; color: var(--text-secondary); }
.card {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; margin-bottom: 16px;
}
.card-title {
font-size: 13px; font-weight: 600; margin-bottom: 16px;
color: var(--accent); text-transform: uppercase; letter-spacing: 0.05em;
}
.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: border-color 0.15s;
}
.input:focus { outline: none; border-color: var(--border-focus); }
.input::placeholder { color: var(--text-muted); }
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; }
.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-primary);
font-size: 13px; cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.btn:hover { background: rgba(255,255,255,0.08); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #000; font-weight: 500; }
.btn-primary:hover { background: #7bb8fc; }
.btn-danger { color: var(--danger); border-color: rgba(248, 81, 73, 0.3); }
.btn-danger:hover { background: rgba(248, 81, 73, 0.1); }
.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) !important; border-color: var(--success) !important; color: #fff !important; pointer-events: none; }
.slider-row { display: flex; align-items: center; gap: 12px; }
.slider-row input[type="range"] { flex: 1; height: 6px; accent-color: var(--accent); cursor: pointer; }
.slider-row .slider-val { min-width: 50px; text-align: right; font-size: 13px; color: var(--text-primary); }
/* 规则编辑器 */
.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: 12px; color: var(--text-secondary); }
.rules-list { max-height: 200px; overflow-y: auto; }
.rules-empty { padding: 20px; text-align: center; color: var(--text-muted); 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-muted); }
.rule-arrow { color: var(--text-muted); font-size: 11px; flex-shrink: 0; }
.rule-delete {
width: 28px; height: 28px; border: none; background: transparent;
color: var(--text-muted); cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.rule-delete:hover { background: rgba(248, 81, 73, 0.1); color: var(--danger); }
/* 当前音色卡片 */
.current-voice-card {
background: linear-gradient(135deg, var(--accent-soft) 0%, var(--bg-secondary) 100%);
border: 1px solid rgba(96, 165, 250, 0.3);
border-radius: 12px; padding: 16px; margin-bottom: 20px;
}
.current-voice-card.source-trial {
background: linear-gradient(135deg, var(--trial-soft) 0%, var(--bg-secondary) 100%);
border-color: rgba(167, 139, 250, 0.3);
}
.current-voice-card.source-trial .current-voice-icon { background: var(--trial-color); }
.current-voice-card.source-auth {
background: linear-gradient(135deg, var(--auth-soft) 0%, var(--bg-secondary) 100%);
border-color: rgba(245, 158, 11, 0.3);
}
.current-voice-card.source-auth .current-voice-icon { background: var(--auth-color); }
.current-voice-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.current-voice-display { display: flex; align-items: center; gap: 12px; }
.current-voice-icon {
width: 48px; height: 48px; border-radius: 50%;
background: var(--accent); color: #000;
display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.current-voice-info { flex: 1; }
.current-voice-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.current-voice-source { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
/* 来源标签 */
.source-badge {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 6px; border-radius: 4px; font-size: 9px; font-weight: 600;
}
.source-badge.trial { background: var(--trial-soft); color: var(--trial-color); }
.source-badge.auth { background: var(--auth-soft); color: var(--auth-color); }
/* 音色 Tabs */
.voice-tabs {
display: flex; gap: 4px; margin-bottom: 16px;
background: var(--bg-tertiary); padding: 4px; border-radius: 10px;
}
.voice-tab {
flex: 1; padding: 10px 12px; border: none; border-radius: 8px;
background: transparent; color: var(--text-secondary);
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-primary); }
.voice-tab.active { background: var(--bg-secondary); color: var(--accent); font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.voice-tab-count {
background: var(--bg-input); padding: 2px 6px; border-radius: 10px;
font-size: 10px; color: var(--text-muted);
}
.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 {
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-muted); margin-top: 6px; min-height: 16px; }
.test-voice-status.playing { color: var(--success); }
.test-voice-status.error { color: var(--danger); }
/* 音色筛选 */
.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-search { display: flex; gap: 8px; margin-bottom: 12px; }
.voice-search .input { flex: 1; }
/* 音色列表 */
.voice-list { display: flex; flex-direction: column; gap: 6px; 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(--accent); background: rgba(96, 165, 250, 0.05); }
.voice-item.selected { border-color: var(--success); background: var(--success-soft); }
.voice-item.in-my-list { opacity: 0.6; }
.voice-item.disabled { opacity: 0.5; 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-muted); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.voice-item.selected .voice-item-radio { border-color: var(--success); background: var(--success); }
.voice-item.selected .voice-item-radio::after { content: '✓'; color: #fff; 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; }
.voice-item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.voice-item-actions { display: flex; gap: 4px; flex-shrink: 0; }
/* 编辑模式 */
.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; }
/* 添加表单 */
.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 配置状态 */
.api-status-box {
display: flex; align-items: center; gap: 10px;
padding: 12px; background: var(--bg-tertiary);
border: 1px solid var(--border); border-radius: 8px;
margin-bottom: 16px;
}
.api-status-box.configured { border-color: rgba(63, 185, 80, 0.3); }
.api-status-box.not-configured { border-color: rgba(248, 81, 73, 0.3); }
.api-status-icon {
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.api-status-box.configured .api-status-icon { background: var(--success-soft); color: var(--success); }
.api-status-box.not-configured .api-status-icon { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
.api-status-info { flex: 1; }
.api-status-title { font-size: 13px; font-weight: 500; }
.api-status-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
/* 统计卡片 */
.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(--accent); }
.stats-label { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
/* 提示框 */
.tip-box {
display: flex; gap: 10px; padding: 12px 14px; background: var(--accent-soft);
border: 1px solid rgba(96, 165, 250, 0.2); 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; }
.tip-box.warning { background: rgba(210, 153, 34, 0.15); border-color: rgba(210, 153, 34, 0.3); }
.tip-box.warning i { color: var(--warning); }
/* 指南卡片 */
.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); }
.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(--warning); font-family: monospace;
}
.guide-box pre {
background: var(--bg-input); padding: 12px; border-radius: 6px;
font-size: 12px; color: var(--text-primary); 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;
}
.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-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-muted); font-size: 10px; cursor: pointer; padding: 8px 4px;
}
.mobile-nav-item i { font-size: 18px; }
.mobile-nav-item.active { color: var(--accent); }
/* 移动端适配 - 关键修复 */
@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确保内容不被导航栏遮挡 */
padding-bottom: calc(80px + var(--safe-area-bottom));
}
/* 视图内的底部按钮额外margin */
.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; }
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
.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 trial"><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-xmark"></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;">遇到「起始」后跳过,直到「结束」。<strong>起始或结束可单独留空</strong>,留空适用于单标签。</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;"><strong>起始或结束可单独留空</strong>,留空适用于单标签。</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>
<!-- 三个 Tab我的音色 / 试用音色 / 预设音色库 -->
<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;"></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>
<p class="form-hint" style="margin-bottom: 12px;">无需配置,立即可用的 11 个音色</p>
<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="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);
const language = model === 'tts2' ? 'multi' : (value.startsWith('en_') ? 'en' : '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-check';
title.textContent = '已配置';
desc.textContent = '可使用预设音色库和复刻音色';
badge.className = 'header-badge auth';
badge.innerHTML = '<i class="fa-solid fa-circle"></i><span>鉴权已配置</span>';
$('authVoiceNotice').style.display = 'none';
} else {
box.className = 'api-status-box not-configured';
icon.className = 'fa-solid fa-xmark';
title.textContent = '未配置';
desc.textContent = '配置后可使用预设音色库和复刻音色';
badge.className = 'header-badge';
badge.innerHTML = '<i class="fa-solid fa-circle"></i><span>鉴权未配置</span>';
$('authVoiceNotice').style.display = 'flex';
}
}
function updateCurrentVoiceDisplay() {
const nameEl = $('currentVoiceName');
const sourceEl = $('currentVoiceSource');
const card = $('currentVoiceCard');
card.className = 'current-voice-card';
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>';
card.classList.add(source === 'free' ? 'source-trial' : 'source-auth');
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) : '',
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 消息处理
// ═══════════════════════════════════════════════════════════════════════════
// Guarded by origin/source check.
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';
});
// API 配置变化时更新状态
['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);
// 音色 Tab 切换
$$('.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>