Files
CC-StationBroadcast/CloudServer/public/generator.html
T
Henry_Du f530d7e045 refactor: 重构文件存储路径并清理冗余代码
- 将项目文件存储从 public/uploads 迁移至项目根目录专用目录
- 更新所有前端页面的路径提示文本与API请求路径
- 移除冗余的列车详情展开收起功能及相关代码
- 重构服务器端文件路径处理逻辑,增强路径访问安全性
- 清理旧注释和未使用的变量与函数
2026-07-31 19:41:26 +08:00

1449 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Broadcast Generator & Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #121214;
--bg-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--accent: #ffffff;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
body {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.container {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.panel:hover {
box-shadow: 0 10px 40px -10px rgba(0,0,0,0.7);
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: -0.015em;
display: flex;
align-items: center;
gap: 8px;
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
padding-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
input[type="text"], textarea, select {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-ui);
font-size: 0.95rem;
outline: none;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
input[type="text"]:focus, textarea:focus, select:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
/* Custom Select Styles */
.custom-select-container {
position: relative;
width: 100%;
font-family: var(--font-ui);
font-size: 0.95rem;
user-select: none;
}
.custom-select-trigger {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.custom-select-trigger:hover {
border-color: #52525b;
}
.custom-select-trigger.active {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.custom-select-trigger svg {
transition: transform 0.2s;
}
.custom-select-trigger.active svg {
transform: rotate(180deg);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 6px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
z-index: 150;
max-height: 200px;
overflow-y: auto;
display: none;
flex-direction: column;
padding: 4px;
}
.custom-select-options.active {
display: flex;
}
.custom-select-option {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
transition: background 0.1s;
}
.custom-select-option:hover {
background: var(--bg-hover);
}
.custom-select-option.selected {
background: var(--bg-hover);
color: var(--accent);
font-weight: 500;
}
input[type="text"]::placeholder {
color: #52525b;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 18px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.audio-preview {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-input);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
}
audio {
flex: 1;
height: 36px;
outline: none;
}
audio::-webkit-media-controls-panel {
background-color: var(--text-primary);
}
.status {
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
min-height: 20px;
}
.list-manager {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.list-item {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 16px;
align-items: center;
background: var(--bg-input);
padding: 12px 16px;
border: 1px solid var(--bg-border);
border-radius: 6px;
transition: all 0.2s;
}
.list-item:hover {
border-color: #52525b;
}
.list-item input {
padding: 8px 12px;
font-size: 0.9rem;
font-family: var(--font-mono);
background: var(--bg-panel);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.btn-icon.danger:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.1);
}
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
backdrop-filter: blur(8px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 12px;
width: 90%;
max-width: 500px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
transform: scale(0.95);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-overlay.active .modal {
transform: scale(1);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.15rem;
}
.modal-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 12px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
/* Editable File List */
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 250px;
overflow-y: auto;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-input);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #52525b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #71717a;
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #52525b var(--bg-input);
}
.edit-file-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--bg-border);
transition: all 0.2s;
}
.edit-file-item:hover {
border-color: #52525b;
}
.edit-file-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-primary);
}
.edit-file-actions {
display: flex;
gap: 6px;
}
.file-adder {
display: flex;
gap: 12px;
}
.file-adder select {
flex: 1;
}
.info-box {
font-size: 0.85rem;
color: var(--text-muted);
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
border-left: 3px solid #52525b;
}
.info-box.warning {
color: var(--warning);
background: rgba(245, 158, 11, 0.05);
border-left-color: var(--warning);
}
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>Broadcast Generator & Manager</h2>
<a href="/" class="btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back to Console
</a>
</div>
<div class="container">
<!-- Panel 1: TTS Generator -->
<div class="panel">
<div class="panel-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="22"></line></svg>
Generate Audio
</div>
<div class="panel-subtitle">Use GPT-SoVITS to generate station announcements.</div>
<!-- Tab Navigation -->
<div style="display: flex; gap: 10px; margin-bottom: 16px; border-bottom: 1px solid var(--bg-border); padding-bottom: 10px;">
<button class="btn btn-primary" id="tab-single" onclick="switchTab('single')" style="padding: 6px 12px; font-size: 0.85rem;">Single</button>
<button class="btn" id="tab-batch" onclick="switchTab('batch')" style="padding: 6px 12px; font-size: 0.85rem;">Batch</button>
</div>
<div class="info-box">
<strong>Notice:</strong> Using server-side proxy to generate audio. Make sure your API balance is sufficient.
</div>
<!-- Single Generation View -->
<div id="view-single" style="display: flex; flex-direction: column; gap: 20px;">
<div class="form-group">
<label>Chinese Station Name (e.g., 北京)</label>
<input type="text" id="zh-text" placeholder="输入中文站名">
</div>
<div class="form-group">
<label>English Station Name (e.g., Beijing)</label>
<input type="text" id="en-text" placeholder="Enter English Name">
</div>
<button class="btn btn-primary" id="btn-generate" onclick="generateAudio()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
Generate Audio
</button>
<div class="status" id="gen-status"></div>
<div style="margin-top: 10px; display: none; flex-direction: column; gap: 16px;" id="preview-section">
<div class="form-group">
<label>Chinese Preview</label>
<div class="audio-preview">
<audio id="zh-audio" controls></audio>
</div>
</div>
<div class="form-group">
<label>English Preview</label>
<div class="audio-preview">
<audio id="en-audio" controls></audio>
</div>
</div>
<div class="form-group" style="margin-top:12px; padding-top:20px; border-top:1px solid var(--bg-border)">
<label>Station Code (e.g., 01-01)</label>
<input type="text" id="station-code" placeholder="01-01">
</div>
<button class="btn btn-primary" id="btn-import" onclick="importAudio()" style="margin-top:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Import & Add to List
</button>
</div>
</div>
<!-- Batch Generation View -->
<div id="view-batch" style="display: none; flex-direction: column; gap: 20px;">
<div class="form-group" id="batch-input-group">
<label>Paste Data (Format: ID &lt;Tab&gt; Chinese &lt;Tab&gt; English)</label>
<textarea id="batch-input" rows="8" placeholder="01-01&#9;千叶&#9;Chiba&#10;CK-01&#9;沼津&#9;Numazu" style="resize: vertical; font-family: var(--font-mono); white-space: pre;"></textarea>
<button class="btn btn-primary" id="btn-batch-preview" onclick="previewBatch()" style="margin-top: 10px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
Preview Tasks
</button>
</div>
<!-- Batch Preview Area -->
<div id="batch-preview-area" style="display: none; flex-direction: column; gap: 12px;">
<div style="font-weight: 500; display: flex; justify-content: space-between; align-items: center;">
<span>Tasks Preview</span>
<button class="btn-icon" id="btn-batch-cancel" onclick="cancelBatchPreview()" title="Edit Data">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
</div>
<div id="batch-preview-list" class="edit-files-list" style="max-height: 400px;"></div>
<div style="display: flex; gap: 10px;">
<button class="btn btn-primary" id="btn-batch-generate" onclick="startBatchGeneration()" style="flex: 1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
Start Generation
</button>
<button class="btn btn-primary" id="btn-batch-import" onclick="importBatchAudio()" style="flex: 1; display: none; background: var(--success); border-color: var(--success);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Import All to List
</button>
</div>
</div>
<div class="status" id="batch-status" style="text-align: left; white-space: pre-wrap; font-family: var(--font-mono); font-size: 0.8rem; background: var(--bg-input); padding: 10px; border-radius: 6px; border: 1px solid var(--bg-border); max-height: 250px; overflow-y: auto; display: none;"></div>
</div>
</div>
<!-- Panel 2: list.txt Manager (Graphical) -->
<div class="panel">
<div class="panel-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
Manage list.txt
</div>
<div style="display:flex; justify-content:space-between; align-items: flex-end; margin-bottom: 8px;">
<div class="panel-subtitle" style="margin:0; border:none; padding:0;">Manage station audio sequences visually. (Supports multiple IDs separated by /)</div>
<div style="display:flex; gap:10px; flex-shrink: 0;">
<button class="btn" onclick="openAddModal()" style="white-space: nowrap;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Add Station
</button>
<button class="btn btn-primary" id="btn-save-list" onclick="saveList()" style="white-space: nowrap;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
Save to Server
</button>
</div>
</div>
<div class="list-manager" id="list-container" style="max-height: 500px; overflow-y: auto; padding-right: 4px;">
<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>
</div>
<div class="info-box warning" style="margin-top:auto;">
<strong>Note:</strong> After generating audio and saving the list, don't forget to go back to the main console and <strong>Publish a New Version</strong> to sync changes!
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Edit Station</span>
<button class="btn-icon" onclick="closeModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Station Code</label>
<input type="text" id="edit-code" placeholder="e.g. 01-01 / 01-01/01-02">
</div>
<div class="form-group">
<label>Playback Sequence (Plays in order from top to bottom)</label>
<div class="edit-files-list" id="edit-files-list">
<!-- Files populated here -->
</div>
</div>
<div class="form-group">
<label>Add Audio File</label>
<div class="file-adder">
<!-- Custom Select Component -->
<div class="custom-select-container" id="custom-file-select" tabindex="0">
<div class="custom-select-trigger" onclick="toggleCustomSelect()">
<span id="custom-select-label">-- Select an audio file --</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="custom-select-options" id="custom-select-options">
<!-- Options will be populated here by JS -->
</div>
</div>
<button class="btn" onclick="addFileToSequence()">Add</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="confirmEdit()">Confirm</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
const ZH_MODEL = "7b64d954530d490a8c40827e618e55c6";
const EN_MODEL = "fe09163b345448eabfeccb25dc435c43";
let zhTempFile = null;
let enTempFile = null;
let listData = [];
let availableFiles = [];
let editingIndex = -1; // -1 means adding new
let currentSequence = [];
let selectedFileValue = '';
function toggleCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
const next = !options.classList.contains('active');
trigger.classList.toggle('active', next);
options.classList.toggle('active', next);
}
function closeCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
trigger.classList.remove('active');
options.classList.remove('active');
}
function selectCustomOption(value, label) {
selectedFileValue = value || '';
const labelEl = document.getElementById('custom-select-label');
if (labelEl) labelEl.textContent = label || '-- Select an audio file --';
const optionsContainer = document.getElementById('custom-select-options');
if (optionsContainer) {
Array.from(optionsContainer.querySelectorAll('.custom-select-option')).forEach(el => {
el.classList.toggle('selected', (el.getAttribute('data-value') || '') === selectedFileValue);
});
}
closeCustomSelect();
}
document.addEventListener('click', (e) => {
const container = document.getElementById('custom-file-select');
if (!container) return;
if (!container.contains(e.target)) closeCustomSelect();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeCustomSelect();
});
// Icons
const ICONS = {
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
};
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div>
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}
function switchTab(tab) {
const btnSingle = document.getElementById('tab-single');
const btnBatch = document.getElementById('tab-batch');
const viewSingle = document.getElementById('view-single');
const viewBatch = document.getElementById('view-batch');
if (tab === 'single') {
btnSingle.className = 'btn btn-primary';
btnBatch.className = 'btn';
viewSingle.style.display = 'flex';
viewBatch.style.display = 'none';
} else {
btnSingle.className = 'btn';
btnBatch.className = 'btn btn-primary';
viewSingle.style.display = 'none';
viewBatch.style.display = 'flex';
}
}
async function _generateSingleTTS(text, reference_id) {
const res = await fetch('/api/tts/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, reference_id })
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
return data;
}
async function _importSingleAudio(code, zhTemp, enTemp) {
const res = await fetch('/api/tts/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ stationCode: code, zhTemp, enTemp })
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
return data;
}
let batchTasks = [];
function parseBatchInput(input) {
const lines = input.split('\n').map(l => l.trim()).filter(l => l);
const tasks = [];
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(/\t+/);
if (parts.length >= 3) {
tasks.push({ code: parts[0].trim(), zh: parts[1].trim(), en: parts[2].trim() });
} else {
const spaceParts = lines[i].split(/\s{2,}/);
if (spaceParts.length >= 3) {
tasks.push({ code: spaceParts[0].trim(), zh: spaceParts[1].trim(), en: spaceParts[2].trim() });
}
}
}
return tasks;
}
function previewBatch() {
const input = document.getElementById('batch-input').value.trim();
if (!input) {
showToast("Error", "Please paste batch data first");
return;
}
batchTasks = parseBatchInput(input);
if (batchTasks.length === 0) {
showToast("Error", "No valid tasks parsed. Make sure data is separated by tabs.");
return;
}
const previewList = document.getElementById('batch-preview-list');
previewList.innerHTML = '';
batchTasks.forEach((task, index) => {
// Initialize temp vars
task.zhTempName = null;
task.enTempName = null;
task.zhUrl = null;
task.enUrl = null;
const item = document.createElement('div');
item.className = 'edit-file-item';
item.style.flexDirection = 'column';
item.style.alignItems = 'stretch';
item.style.gap = '8px';
item.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div style="display:flex; flex-direction:column; gap:2px;">
<span style="font-weight:600; color:var(--accent)">${task.code}</span>
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${task.zh} | EN: ${task.en}</span>
</div>
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
</div>
<div id="batch-audio-preview-${index}" style="display:none; flex-direction:column; gap:6px; margin-top:4px; padding-top:8px; border-top:1px solid var(--bg-border);">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:0.75rem; color:var(--text-muted); width:20px;">ZH</span>
<audio id="batch-audio-zh-${index}" controls style="height:24px;"></audio>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:0.75rem; color:var(--text-muted); width:20px;">EN</span>
<audio id="batch-audio-en-${index}" controls style="height:24px;"></audio>
</div>
</div>
`;
item.id = `batch-task-${index}`;
previewList.appendChild(item);
});
document.getElementById('batch-input-group').style.display = 'none';
document.getElementById('batch-preview-area').style.display = 'flex';
document.getElementById('btn-batch-import').style.display = 'none';
document.getElementById('btn-batch-generate').style.display = 'flex';
document.getElementById('btn-batch-cancel').style.display = 'block';
}
function cancelBatchPreview() {
document.getElementById('batch-preview-area').style.display = 'none';
document.getElementById('batch-status').style.display = 'none';
document.getElementById('batch-input-group').style.display = 'flex';
}
async function startBatchGeneration() {
if (batchTasks.length === 0) return;
const btn = document.getElementById('btn-batch-generate');
const btnCancel = document.getElementById('btn-batch-cancel');
const statusBox = document.getElementById('batch-status');
btn.disabled = true;
btnCancel.style.display = 'none'; // Prevent cancel during generation
statusBox.style.display = 'block';
statusBox.innerHTML = `Starting batch generation for ${batchTasks.length} stations...\n`;
let successCount = 0;
let failCount = 0;
for (let i = 0; i < batchTasks.length; i++) {
const task = batchTasks[i];
const taskEl = document.getElementById(`batch-task-${i}`);
const statusSpan = document.getElementById(`batch-status-span-${i}`);
statusSpan.textContent = 'Generating...';
statusSpan.style.color = 'var(--warning)';
statusBox.innerHTML += `\n[${i+1}/${batchTasks.length}] Generating ${task.code} (${task.zh})... `;
statusBox.scrollTop = statusBox.scrollHeight;
let retryCount = 0;
let success = false;
let lastError = "";
while (retryCount < 2 && !success) {
try {
// 1. Generate ZH
const zhData = await _generateSingleTTS(task.zh, ZH_MODEL);
// 2. Generate EN
const enData = await _generateSingleTTS(task.en, EN_MODEL);
task.zhTempName = zhData.tempName;
task.enTempName = enData.tempName;
task.zhUrl = zhData.url;
task.enUrl = enData.url;
success = true;
} catch (e) {
lastError = e.message;
retryCount++;
if (retryCount < 2) {
statusBox.innerHTML += `<span style="color:var(--warning)">[Retry ${retryCount}] </span>`;
await new Promise(r => setTimeout(r, 2000)); // wait 2s before retry
}
}
}
if (success) {
statusBox.innerHTML += `<span style="color:var(--success)">OK</span>`;
statusSpan.textContent = 'Generated';
statusSpan.style.color = 'var(--success)';
taskEl.style.borderColor = 'var(--success)';
// Show audio players
const previewDiv = document.getElementById(`batch-audio-preview-${i}`);
previewDiv.style.display = 'flex';
document.getElementById(`batch-audio-zh-${i}`).src = task.zhUrl;
document.getElementById(`batch-audio-en-${i}`).src = task.enUrl;
successCount++;
} else {
statusBox.innerHTML += `<span style="color:var(--danger)">FAILED (${lastError})</span>`;
statusSpan.textContent = 'Failed';
statusSpan.style.color = 'var(--danger)';
taskEl.style.borderColor = 'var(--danger)';
failCount++;
}
}
statusBox.innerHTML += `\n\n<b>Generation Complete!</b> Success: ${successCount}, Failed: ${failCount}`;
statusBox.scrollTop = statusBox.scrollHeight;
btn.style.display = 'none'; // Hide generate button
if (successCount > 0) {
statusBox.innerHTML += `\n<i>Please preview the audio files above, then click 'Import All to List' to save them.</i>`;
statusBox.scrollTop = statusBox.scrollHeight;
document.getElementById('btn-batch-import').style.display = 'flex'; // Show import button
document.getElementById('btn-batch-cancel').style.display = 'block'; // Allow cancel/discard
} else {
btn.disabled = false;
btn.style.display = 'flex';
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v6h6"></path></svg> Retry Generation`;
document.getElementById('btn-batch-cancel').style.display = 'block';
}
}
async function importBatchAudio() {
const btn = document.getElementById('btn-batch-import');
const btnCancel = document.getElementById('btn-batch-cancel');
const statusBox = document.getElementById('batch-status');
btn.disabled = true;
btnCancel.style.display = 'none';
statusBox.innerHTML += `\n\nStarting import process...\n`;
statusBox.scrollTop = statusBox.scrollHeight;
let importCount = 0;
for (let i = 0; i < batchTasks.length; i++) {
const task = batchTasks[i];
if (!task.zhTempName || !task.enTempName) continue; // Skip failed ones
// Replace slashes with dashes to ensure filesystem compatibility
if (task.code.includes('/')) {
console.log(`Replacing slashes in batch code: ${task.code}`);
task.code = task.code.replace(/\//g, '-');
}
const statusSpan = document.getElementById(`batch-status-span-${i}`);
statusSpan.textContent = 'Importing...';
statusSpan.style.color = 'var(--warning)';
try {
await _importSingleAudio(task.code, task.zhTempName, task.enTempName);
// Update listData
const filesStr = `ch-${task.code}.wav,en-${task.code}.wav`;
const existingIndex = listData.findIndex(item => item.code === task.code);
if (existingIndex >= 0) {
listData[existingIndex].files = filesStr.split(',');
} else {
listData.push({ code: task.code, files: filesStr.split(',') });
}
statusSpan.textContent = 'Imported';
statusSpan.style.color = 'var(--success)';
importCount++;
} catch (e) {
statusBox.innerHTML += `\n<span style="color:var(--danger)">Failed to import ${task.code}: ${e.message}</span>`;
statusBox.scrollTop = statusBox.scrollHeight;
statusSpan.textContent = 'Import Failed';
statusSpan.style.color = 'var(--danger)';
document.getElementById(`batch-task-${i}`).style.borderColor = 'var(--danger)';
}
}
if (importCount > 0) {
renderList();
saveList();
showToast("Batch Complete", `${importCount} stations imported and added to list`);
statusBox.innerHTML += `\n<b>All ${importCount} items imported successfully!</b>`;
// Reset UI after 2 seconds
setTimeout(() => {
cancelBatchPreview();
document.getElementById('batch-input').value = '';
btn.disabled = false;
}, 2000);
} else {
btn.disabled = false;
btnCancel.style.display = 'block';
}
statusBox.scrollTop = statusBox.scrollHeight;
}
async function generateAudio() {
const zhText = document.getElementById('zh-text').value.trim();
const enText = document.getElementById('en-text').value.trim();
if (!zhText || !enText) {
showToast("Error", "Please enter both Chinese and English names");
return;
}
const btn = document.getElementById('btn-generate');
const status = document.getElementById('gen-status');
btn.disabled = true;
btn.textContent = "Generating...";
status.textContent = "Generating Chinese audio...";
try {
// Generate Chinese via Server-Side Proxy
const resZh = await fetch('/api/tts/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: zhText,
reference_id: ZH_MODEL
})
});
const dataZh = await resZh.json();
if (!dataZh.success) throw new Error("ZH TTS failed: " + dataZh.message);
status.textContent = "Generating English audio...";
// Generate English via Server-Side Proxy
const resEn = await fetch('/api/tts/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: enText,
reference_id: EN_MODEL
})
});
const dataEn = await resEn.json();
if (!dataEn.success) throw new Error("EN TTS failed: " + dataEn.message);
// Success
zhTempFile = dataZh.tempName;
enTempFile = dataEn.tempName;
document.getElementById('zh-audio').src = dataZh.url;
document.getElementById('en-audio').src = dataEn.url;
document.getElementById('preview-section').style.display = 'block';
status.textContent = "Generation complete!";
showToast("Success", "Audio generated successfully");
} catch (e) {
status.textContent = "Error: " + e.message;
showToast("Error", e.message);
} finally {
btn.disabled = false;
btn.textContent = "Generate Audio";
}
}
async function importAudio() {
let code = document.getElementById('station-code').value.trim();
if (!code) {
showToast("Error", "Please enter a station code");
return;
}
// Replace slashes with dashes to ensure filesystem compatibility
if (code.includes('/')) {
console.log(`Replacing slashes in code: ${code}`);
code = code.replace(/\//g, '-');
}
if (!zhTempFile || !enTempFile) {
showToast("Error", "No audio generated to import");
return;
}
const btn = document.getElementById('btn-import');
btn.disabled = true;
btn.textContent = "Importing...";
try {
const res = await fetch('/api/tts/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
stationCode: code,
zhTemp: zhTempFile,
enTemp: enTempFile
})
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
showToast("Success", "Files imported to Station folder!");
// Automatically add/update to listData if generated via this panel
const filesStr = `ch-${code}.wav,en-${code}.wav`;
const existingIndex = listData.findIndex(item => item.code === code);
if (existingIndex >= 0) {
listData[existingIndex].files = filesStr.split(',');
} else {
listData.push({ code: code, files: filesStr.split(',') });
}
renderList();
saveList(); // Auto-save list.txt
// Reset
zhTempFile = null;
enTempFile = null;
document.getElementById('preview-section').style.display = 'none';
document.getElementById('gen-status').textContent = "";
document.getElementById('zh-text').value = "";
document.getElementById('en-text').value = "";
} catch (e) {
showToast("Error", "Import failed: " + e.message);
} finally {
btn.disabled = false;
btn.textContent = "Import to Station Folder & Add to List";
}
}
// --- List Manager ---
async function fetchAvailableFiles() {
try {
const res = await fetch('/api/files');
const data = await res.json();
if (data.files) {
availableFiles = data.files
.filter(f => f.type === 'station' || f.type === 'general')
.map(f => f.name);
availableFiles.sort();
populateFileSelect();
}
} catch (e) {
console.error("Failed to load files", e);
}
}
function populateFileSelect() {
const optionsContainer = document.getElementById('custom-select-options');
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
availableFiles.forEach(f => {
const isSelected = selectedFileValue === f ? 'selected' : '';
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
});
optionsContainer.innerHTML = html;
}
async function loadList() {
try {
const res = await fetch('/api/files/content?path=list.txt');
const data = await res.json();
listData = [];
if (data.success && data.content) {
const lines = data.content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^=]+)==(.+)$/);
if (match) {
listData.push({ code: match[1], files: match[2].split(',').filter(f => f) });
}
}
}
renderList();
} catch (e) {
document.getElementById('list-container').innerHTML = '<div style="color:var(--danger)">Failed to load list.txt</div>';
}
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
listData.sort((a, b) => a.code.localeCompare(b.code));
if (listData.length === 0) {
container.innerHTML = '<div style="text-align:center; color:var(--text-muted)">No entries found</div>';
return;
}
listData.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'list-item';
div.style.gridTemplateColumns = '100px 1fr auto';
const tagsHtml = item.files.map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${f}</span>`).join('');
div.innerHTML = `
<div style="font-family:var(--font-mono); font-weight:600;">${item.code}</div>
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
<div style="display:flex; gap:8px;">
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
<button class="btn-icon" onclick="removeListItem(${index})">${ICONS.trash}</button>
</div>
`;
container.appendChild(div);
});
}
function removeListItem(index) {
if (confirm(`Delete station ${listData[index].code}?`)) {
listData.splice(index, 1);
renderList();
}
}
// --- Modal Logic ---
function openAddModal() {
editingIndex = -1;
currentSequence = [];
document.getElementById('modal-title').textContent = 'Add Station Configuration';
document.getElementById('edit-code').value = '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function openEditModal(index) {
editingIndex = index;
const item = listData[index];
currentSequence = [...item.files];
document.getElementById('modal-title').textContent = `Edit Station: ${item.code}`;
document.getElementById('edit-code').value = item.code;
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
closeCustomSelect();
document.getElementById('edit-modal').classList.remove('active');
}
function renderSequence() {
const list = document.getElementById('edit-files-list');
list.innerHTML = '';
if (currentSequence.length === 0) {
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.85rem; text-align:center; padding:10px;">No files added yet</div>';
return;
}
currentSequence.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'edit-file-item';
div.innerHTML = `
<div class="edit-file-name">${file}</div>
<div class="edit-file-actions">
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3"' : ''}>${ICONS.up}</button>
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.3"' : ''}>${ICONS.down}</button>
<button class="btn-icon" onclick="removeFileFromSequence(${idx})" style="color:var(--danger); margin-left:8px">${ICONS.trash}</button>
</div>
`;
list.appendChild(div);
});
}
function addFileToSequence() {
const file = selectedFileValue;
if (!file) return;
currentSequence.push(file);
renderSequence();
selectCustomOption('', '-- Select an audio file --');
}
function removeFileFromSequence(idx) {
currentSequence.splice(idx, 1);
renderSequence();
}
function moveFile(idx, dir) {
if (idx + dir >= 0 && idx + dir < currentSequence.length) {
const temp = currentSequence[idx];
currentSequence[idx] = currentSequence[idx + dir];
currentSequence[idx + dir] = temp;
renderSequence();
}
}
function confirmEdit() {
const code = document.getElementById('edit-code').value.trim();
if (!code) {
alert("Station code is required");
return;
}
if (editingIndex === -1) {
if (listData.some(item => item.code === code)) {
alert(`Station ${code} already exists!`);
return;
}
listData.push({ code, files: [...currentSequence] });
} else {
listData[editingIndex].code = code;
listData[editingIndex].files = [...currentSequence];
}
renderList();
closeModal();
}
async function saveList() {
const btn = document.getElementById('btn-save-list');
btn.disabled = true;
btn.textContent = "Saving...";
const content = listData
.filter(item => item.code)
.map(item => `${item.code}==${item.files.join(',')}`)
.join('\n');
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
path: 'list.txt',
content: content + '\n'
})
});
const data = await res.json();
if (data.success) {
showToast("Success", "list.txt saved successfully!");
} else {
throw new Error(data.message);
}
} catch (e) {
showToast("Error", "Save failed: " + e.message);
} finally {
btn.disabled = false;
btn.textContent = "Save to Server";
}
}
// Init
async function init() {
await fetchAvailableFiles();
await loadList();
}
init();
</script>
</body>
</html>