Files
CC-StationBroadcast/CloudServer/public/index.html
T
Henry_Du 5e466f4553 fix(ui): 补齐 generator.html 和 listmanager.html 的问号剥离兜底
- generator.html: 新增 ds() 钩子,在 renderList / batch 预览 / showToast 三个渲染点统一问号剥离
- listmanager.html: 新增 ds() 钩子,在 renderList / renderSequence / 自定义 select 选项 / showToast 四个点统一问号剥离
- APP_VERSION_TEXT bump 到 v1.0.107
2026-08-17 17:32:01 +08:00

1914 lines
70 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 Console</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 {
/* Ultra-Dark / Minimalist Theme (aligned with cablemanager) */
--bg-body: #09090b;
--bg-panel: #121214;
--bg-input: #09090b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--accent: #fafafa;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
* {
box-sizing: border-box;
}
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: 0;
height: 100vh;
display: grid;
grid-template-rows: 1fr 4px 300px;
overflow: hidden;
}
.mobile-only {
display: none;
}
/* --- Splitter / Resizer --- */
.resizer-horizontal {
height: 4px;
background: var(--bg-border);
cursor: row-resize;
transition: background 0.2s;
z-index: 50;
}
.resizer-horizontal:hover {
background: var(--text-muted);
}
.resizer-vertical {
width: 4px;
background: var(--bg-border);
cursor: col-resize;
transition: background 0.2s;
z-index: 50;
}
.resizer-vertical:hover {
background: var(--text-muted);
}
/* --- Main Area (Top): Status & File Manager --- */
.main-area {
display: grid;
grid-template-columns: 400px 4px 1fr;
/* Add col for resizer */
background: var(--bg-body);
overflow: hidden;
}
/* Left Sidebar: Status */
.sidebar {
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.version-panel {
padding: 16px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--bg-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.version-panel-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
align-items: center;
}
.version-display {
font-family: var(--font-mono);
font-size: 1.1rem;
color: var(--success);
font-weight: 500;
background: #000;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--bg-border);
text-align: center;
}
.section-header {
padding: 16px 20px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--bg-border);
background: var(--bg-body);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-list {
flex: 1;
overflow-y: auto;
padding: 0;
}
.status-item {
border-bottom: 1px solid var(--bg-border);
transition: background 0.1s;
}
.status-main {
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.status-main:hover {
background: var(--bg-hover);
}
.item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
font-weight: 500;
font-size: 0.95rem;
}
.item-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bg-border);
}
.dot-online {
background: var(--success);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.dot-offline {
background: var(--danger);
}
.dot-warn {
background: var(--warning);
}
.dot-broadcasting {
background: #3b82f6; /* Blue for broadcasting */
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
100% { opacity: 1; transform: scale(1); }
}
/* Expandable Details */
.status-details {
display: none;
background: var(--bg-panel);
padding: 10px 20px;
font-size: 0.8rem;
color: var(--text-muted);
border-top: 1px solid var(--bg-border);
}
.status-details.open {
display: block;
}
.trip-log {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 10px;
padding: 4px 0;
border-bottom: 1px dashed var(--bg-border);
}
.trip-log:last-child {
border-bottom: none;
}
/* Right Content: File Manager */
.content-area {
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.fm-toolbar {
padding: 12px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-body);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-muted);
}
.breadcrumb-item {
cursor: pointer;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--text-primary);
}
.breadcrumb-item.active {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.breadcrumb-sep {
opacity: 0.3;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
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;
text-decoration: none;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-muted);
}
.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;
}
.btn-danger {
border-color: rgba(239, 68, 68, 0.3);
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-icon:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.file-list {
flex: 1;
overflow-y: auto;
}
.file-header {
display: grid;
grid-template-columns: 40px 1fr 120px 100px;
padding: 10px 24px;
border-bottom: 1px solid var(--bg-border);
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
background: var(--bg-body);
z-index: 10;
}
.file-row {
display: grid;
grid-template-columns: 40px 1fr 120px 100px;
padding: 12px 24px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
cursor: pointer;
transition: background 0.1s;
}
.file-row:hover {
background: var(--bg-hover);
}
.file-icon {
width: 20px;
height: 20px;
color: var(--text-muted);
}
.file-name {
font-weight: 500;
margin-left: 12px;
}
.file-meta {
font-size: 0.8rem;
color: var(--text-muted);
}
/* --- Console (Bottom) --- */
.console-area {
background: #000;
/* Pure black for console */
border-top: 1px solid var(--bg-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.console-header {
padding: 8px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--bg-border);
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
}
.console-logs {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 12px;
line-height: 1.5;
}
.log-time {
color: var(--text-muted);
opacity: 0.5;
white-space: nowrap;
}
.log-msg {
color: #d4d4d8;
white-space: pre-wrap;
word-break: break-all;
}
.type-info {
color: #3b82f6;
}
.type-warn {
color: #eab308;
}
.type-err {
color: #ef4444;
}
.type-success {
color: #10b981;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-weight: 600;
font-size: 1.1rem;
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 12px;
background: var(--bg-body);
}
textarea.editor {
width: 100%;
height: 400px;
background: var(--bg-body);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
padding: 16px;
resize: none;
outline: none;
}
textarea.editor:focus {
border-color: var(--text-muted);
}
/* Custom Audio Player */
.audio-player {
background: var(--bg-body);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.play-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--text-primary);
color: var(--bg-body);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.1s;
}
.play-btn:active {
transform: scale(0.95);
}
.progress-container {
flex: 1;
height: 6px;
background: var(--bg-hover);
border-radius: 3px;
position: relative;
cursor: pointer;
}
.progress-bar {
height: 100%;
background: var(--text-primary);
border-radius: 3px;
width: 0%;
transition: width 0.1s linear;
}
.time-display {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
min-width: 80px;
text-align: right;
}
/* Toasts */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 12px 20px;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: var(--text-primary);
}
.toast-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 2px;
}
.toast-msg {
font-size: 0.8rem;
color: var(--text-muted);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Utilities */
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
border: 1px solid var(--text-muted);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
input[type="checkbox"]:checked {
background: var(--text-primary);
border-color: var(--text-primary);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type="file"] {
display: none;
}
input[type="text"], input[type="number"], input[type="password"], 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-mono);
font-size: 0.9rem;
outline: none;
transition: all 0.2s;
height: 40px;
}
textarea {
height: auto;
min-height: 80px;
resize: vertical;
font-family: var(--font-ui);
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'><path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px 16px;
padding-right: 36px;
cursor: pointer;
}
select > option {
background: var(--bg-panel);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
}
input[type="text"]:focus,
input[type="number"]:focus,
input[type="password"]:focus,
textarea:focus,
select:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 6px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
@media (max-width: 900px) {
body {
height: auto;
min-height: 100vh;
overflow: auto;
grid-template-rows: auto auto;
}
.main-area {
grid-template-columns: 1fr;
min-height: auto;
}
.sidebar {
min-width: 0 !important;
max-height: none;
border-bottom: 1px solid var(--bg-border);
}
.content-area {
min-width: 0 !important;
}
.resizer-vertical,
.resizer-horizontal {
display: none;
}
.section-header,
.status-main,
.fm-toolbar,
.file-header,
.file-row,
.console-header,
.console-logs,
.modal-header,
.modal-body,
.modal-footer {
padding-left: 14px;
padding-right: 14px;
}
.status-main {
align-items: flex-start;
gap: 12px;
}
.item-info {
min-width: 0;
flex: 1;
}
.item-title,
.item-sub,
.file-name,
.log-msg,
.toast-msg {
word-break: break-word;
}
.fm-toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
position: sticky;
top: 0;
z-index: 20;
}
.breadcrumb {
overflow-x: auto;
white-space: nowrap;
padding-bottom: 4px;
}
.actions {
justify-content: stretch;
gap: 8px;
}
.actions .btn,
.actions a.btn,
.console-header .btn {
flex: 1 1 calc(50% - 4px);
min-height: 40px;
justify-content: center;
text-align: center;
white-space: normal;
}
.file-header {
display: none;
}
.file-row {
grid-template-columns: 28px 1fr auto;
gap: 10px;
padding-top: 14px;
padding-bottom: 14px;
}
.file-row > :nth-child(3) {
grid-column: 2 / 3;
font-size: 0.75rem;
}
.file-row > :nth-child(4) {
grid-column: 3 / 4;
grid-row: 1 / span 2;
align-self: center;
}
.file-row .file-name {
margin-left: 0;
}
.console-area {
min-height: 42vh !important;
}
.console-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.log-entry {
flex-direction: column;
gap: 2px;
}
.log-time {
font-size: 0.72rem;
}
.modal {
width: calc(100% - 20px);
max-width: none;
max-height: 92vh;
}
textarea.editor {
height: 52vh;
font-size: 0.85rem;
}
.audio-player {
flex-wrap: wrap;
padding: 14px;
}
.progress-container {
order: 3;
width: 100%;
}
.time-display {
min-width: 0;
}
.toast-container {
left: 10px;
right: 10px;
bottom: 10px;
}
.toast {
min-width: 0;
width: 100%;
}
}
@media (max-width: 560px) {
.section-header {
font-size: 0.78rem;
}
.status-main {
padding-top: 10px;
padding-bottom: 10px;
}
.item-title {
font-size: 0.88rem;
}
.item-sub,
.file-meta,
.btn,
.toast-title {
font-size: 0.76rem;
}
.actions .btn,
.actions a.btn {
flex-basis: 100%;
}
.btn-icon {
padding: 6px;
}
}
</style>
</head>
<body>
<!-- Top Area: Sidebar (Status) + File Manager -->
<div class="main-area" id="main-area">
<!-- Sidebar: Status -->
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
<!-- Station Update -->
<div class="version-panel">
<div class="version-panel-header">
<span>Station Update</span>
<div style="display:flex; gap:8px;">
<button class="btn" id="publish-update-btn" onclick="publishStationUpdate()" title="Publish Station Update">发布更新</button>
<button class="btn-icon" onclick="fetchStationUpdate()" title="Refresh Update Status">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</button>
</div>
</div>
<div class="version-display" id="current-version">Loading...</div>
</div>
<!-- Stations -->
<div class="section-header">Active Stations</div>
<div class="status-list" id="station-list">
</div>
<!-- Trains -->
<div class="section-header" style="border-top:1px solid var(--bg-border)">Active Trains</div>
<div class="status-list" id="train-list" style="flex:1;">
</div>
</div>
<!-- Resizer Vertical -->
<div class="resizer-vertical" id="resizer-v"></div>
<!-- Content: Radio Stream -->
<div class="content-area" id="radio-view" style="display:none; min-width: 300px; padding: 20px; overflow-y: auto;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; border-bottom:1px solid var(--bg-border); padding-bottom:10px;">
<h2 style="margin:0; font-weight:500; font-size:1.2rem; color:var(--text-primary);">Global Radio Stream</h2>
<button class="btn" onclick="showFileManager()">Back to Files</button>
</div>
<div id="radio-streams-container" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap:16px;">
<!-- Stream cards will be generated here -->
<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">
Waiting for broadcast streams...
</div>
</div>
</div>
<!-- Content: File Manager -->
<div class="content-area" id="file-manager-view" style="min-width: 300px;">
<div class="fm-toolbar">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="actions">
<span id="selection-count"
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
<a href="/cablemanager.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Cable Manager</a>
<a href="/generator.html" class="btn btn-primary" style="text-decoration:none">Broadcast Generator</a>
<a href="/tempbroadcast.html" class="btn btn-primary" style="text-decoration:none">Temp Broadcast</a>
<a href="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
<a href="/listmanager.html" class="btn btn-primary" style="text-decoration:none">list.txt Manager</a>
<button class="btn" onclick="refreshFiles()">Refresh</button>
<button class="btn" id="btn-delete" disabled onclick="deleteSelected()">Delete</button>
<button class="btn btn-primary" id="btn-upload" onclick="triggerUpload()">Upload</button>
</div>
</div>
<div class="file-header">
<div style="display:flex;justify-content:center">
<input type="checkbox" id="select-all" title="Select All/None" aria-label="Select All/None" onclick="toggleSelectAll()">
</div>
<div>Name</div>
<div>Type</div>
<div style="text-align:right">Action</div>
</div>
<div class="file-list" id="file-list-body">
</div>
</div>
</div>
<!-- Resizer Horizontal -->
<div class="resizer-horizontal" id="resizer-h"></div>
<!-- Bottom Area: Console -->
<div class="console-area" style="min-height: 100px;">
<div class="console-header">
<div style="display:flex;align-items:center;gap:10px">
<span>System Output</span>
<span id="conn-status" style="color:var(--danger)">Disconnected</span>
</div>
<button class="btn" style="padding:2px 8px;font-size:0.7rem" onclick="forceSave()">Force Save Data</button>
</div>
<div class="console-logs" id="console-logs">
</div>
</div>
<!-- Hidden Inputs -->
<input type="file" id="upload-config" accept=".txt" onchange="performUpload('listFile', '/api/upload/list', this)">
<input type="file" id="upload-music" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('musicAudioFiles', '/api/upload/music-audio', this)">
<input type="file" id="upload-voice" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('voiceAudioFiles', '/api/upload/voice-audio', this)">
<input type="file" id="upload-general" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('generalAudioFiles', '/api/upload/general-audio', this)">
<input type="file" id="upload-station" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('stationAudioFiles', '/api/upload/station-audio', this)">
<!-- Preview Modal -->
<div class="modal-overlay" id="preview-modal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-title">File Preview</div>
<button class="btn-icon" onclick="closeModal()" title="Close Modal">
<svg xmlns="http://www.w3.org/2000/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" id="modal-content">
</div>
<div class="modal-footer" id="modal-footer">
<button class="btn" onclick="closeModal()">Close</button>
<button class="btn btn-primary" id="btn-save" onclick="saveFile()">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script>
// --- Display sanitizer: 强制剥除所有 ? 类字符,保证 UI 零问号 ---
function ds(x) {
let v = String(x == null ? '' : x)
.replace(/[?¿?]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return v === '' ? '-' : v;
}
// --- Icons (SVG) ---
const ICONS = {
folder: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#fbbf24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
audio: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#38bdf8"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#94a3b8"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>`,
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>`,
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
chevronUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`
};
// --- State ---
let allFiles = [];
let currentPath = 'root';
let stationCodeMap = {};
let broadcastingStations = new Set();
let currentStationUpdate = null;
// --- WebSocket ---
const connStatus = document.getElementById('conn-status');
let ws;
function connect() {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProto}://${window.location.host}`);
ws.onopen = () => {
connStatus.textContent = 'Connected';
connStatus.style.color = 'var(--success)';
ws.send(JSON.stringify({ type: 'console_connect' }));
};
ws.onclose = () => {
connStatus.textContent = 'Disconnected (Reconnecting...)';
connStatus.style.color = 'var(--danger)';
setTimeout(connect, 3000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init_state' || data.type === 'state_update') {
updateState(data);
if (data.logs && data.type === 'init_state') data.logs.forEach(l => addLog(l));
} else if (data.type === 'log') {
addLog(data.data);
} else if (data.type === 'event') {
showToast(data.title, data.message);
} else if (data.type === 'stream_start') {
broadcastingStations.add(data.station);
updateStationDots();
updateRadioStreams(data.station, data.seq, true, data.hlsUrl);
} else if (data.type === 'stream_end') {
broadcastingStations.delete(data.station);
updateStationDots();
updateRadioStreams(data.station, null, false);
}
};
}
connect();
// --- Renderers ---
function updateState(state) {
if (state.stationUpdate) {
currentStationUpdate = state.stationUpdate;
renderStationUpdate(state.stationUpdate);
}
if (state.stations) {
const list = document.getElementById('station-list');
list.innerHTML = '';
const stList = Array.isArray(state.stations) ? state.stations : Object.values(state.stations);
stList.forEach(st => {
stationCodeMap[st.name] = st.code;
});
if (stList.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active stations</div>';
stList.forEach(st => {
const isOnline = (Date.now() - st.lastSeen) < 60000;
const isBroadcasting = broadcastingStations.has(st.name);
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
const stationTarget = st.target || st.name;
const memberCount = Number(st.memberCount || 1);
const stationVersion = ds(st.version || '');
const targetVersion = ds(currentStationUpdate?.targetVersion || '');
const needsUpdate = !!targetVersion && stationVersion !== targetVersion && stationVersion !== '-' && targetVersion !== '-';
const updateBadge = needsUpdate
? `<span style="font-size:0.65rem; background:rgba(245, 158, 11, 0.12); color:#fbbf24; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(245, 158, 11, 0.25)">待更新 ${targetVersion}</span>`
: '';
const div = document.createElement('div');
div.className = 'status-item';
div.id = `station-item-${String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_')}`;
const dsName = ds(st.name);
const dsCode = ds(st.code || '');
const dsPlatform = st.platform ? ds(st.platform) : '';
const dsVersion = stationVersion === '-' ? '' : stationVersion;
div.innerHTML = `
<div class="status-main">
<div class="item-info">
<div class="item-title">
${dsName}
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
${updateBadge}
</div>
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - Platform ${dsPlatform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}${dsVersion ? ' · ' + dsVersion : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot ${dotClass}" id="station-dot-${String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${dsName}"></div>
<button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
</div>
</div>
`;
list.appendChild(div);
});
}
if (state.activeTrains) {
const list = document.getElementById('train-list');
list.innerHTML = '';
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
state.activeTrains.forEach(tr => {
const div = document.createElement('div');
div.className = 'status-item';
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
const dsTrainId = ds(tr.trainId);
const dsStatus = ds(tr.status || '');
const dsCurCode = ds(currentStCode);
div.innerHTML = `
<div class="status-main">
<div class="item-info">
<div class="item-title">${dsTrainId}</div>
<div class="item-sub">${dsCurCode} <span style="margin-left:8px;color:var(--text-muted)">${dsStatus}</span></div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
<button class="btn-icon" onclick="deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
</div>
</div>
`;
list.appendChild(div);
});
}
}
function showRadioStream() {
document.getElementById('file-manager-view').style.display = 'none';
document.getElementById('radio-view').style.display = 'flex';
document.getElementById('radio-view').style.flexDirection = 'column';
}
function showFileManager() {
document.getElementById('radio-view').style.display = 'none';
document.getElementById('file-manager-view').style.display = 'flex';
document.getElementById('file-manager-view').style.flexDirection = 'column';
}
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
const container = document.getElementById('radio-streams-container');
const dsStation = ds(station || '');
const stationKey = String(station || '').replace(/[^a-zA-Z0-9]/g, '_');
const cardId = `stream-card-${stationKey}`;
if (isStarting) {
// Clear empty state
if (container.innerHTML.includes('Waiting for broadcast streams...')) {
container.innerHTML = '';
}
let card = document.getElementById(cardId);
if (!card) {
card = document.createElement('div');
card.id = cardId;
card.style.cssText = 'background:var(--bg-panel); border:1px solid #3b82f6; border-radius:var(--radius); padding:16px; box-shadow:0 4px 12px rgba(59, 130, 246, 0.15); animation:slideIn 0.3s ease-out; position:relative; overflow:hidden; display:flex; flex-direction:column; gap:12px;';
container.appendChild(card);
}
const seqHtml = Array.isArray(seq) && seq.length > 0
? seq.map(s => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.75rem; font-family:var(--font-mono); margin:2px; display:inline-block;">${ds(s)}</span>`).join('')
: '';
const dsHlsUrl = ds(hlsUrl || '');
const showHls = hlsUrl && dsHlsUrl !== '-';
card.innerHTML = `
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:600; font-size:1.1rem;">${dsStation}</div>
<div class="status-dot dot-broadcasting"></div>
</div>
${showHls ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${String(hlsUrl || '')}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${dsHlsUrl}</a></div>` : ''}
<div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
${seqHtml}
</div>
</div>
`;
} else {
const card = document.getElementById(cardId);
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
card.style.transition = 'all 0.3s';
setTimeout(() => {
card.remove();
if (container.children.length === 0) {
container.innerHTML = '<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">Waiting for broadcast streams...</div>';
}
}, 300);
}
}
}
function updateStationDots() {
const dots = document.querySelectorAll('[id^="station-dot-"]');
dots.forEach(dot => {
const name = dot.getAttribute('data-name');
const isOnline = dot.getAttribute('data-online') === 'true';
const isBroadcasting = broadcastingStations.has(name);
dot.className = 'status-dot ' + (isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'));
});
}
function addLog(logText) {
const container = document.getElementById('console-logs');
const div = document.createElement('div');
div.className = 'log-entry';
const timeMatch = logText.match(/^\[(.*?)\]/);
let time = new Date().toLocaleTimeString();
let content = logText;
let typeClass = '';
if (timeMatch) {
// Try to parse ISO string to local time
const dateStr = timeMatch[1];
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
time = date.toLocaleTimeString();
} else {
// Fallback if not valid date
time = dateStr.split('T')[1]?.split('.')[0] || dateStr;
}
content = logText.substring(timeMatch[0].length).trim();
}
if (content.includes('[ERROR]')) typeClass = 'type-err';
else if (content.includes('[WARN]')) typeClass = 'type-warn';
else if (content.includes('[SUCCESS]')) typeClass = 'type-success';
else typeClass = 'type-info';
content = content.replace(/^\[.*?\]\s*/, '');
const dsTime = ds(time);
const dsContent = ds(content);
div.innerHTML = `<div class="log-time">${dsTime}</div><div class="log-msg"><span class="${typeClass}">></span> ${dsContent}</div>`;
container.prepend(div);
if (container.children.length > 100) container.lastChild.remove();
}
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
const dsTitle = ds(title || '');
const dsMsg = ds(message || '');
// Icon based on title/message
let iconColor = 'var(--text-primary)';
if (title.includes('Arrival')) iconColor = 'var(--success)';
if (title.includes('Departure')) iconColor = 'var(--warning)';
toast.innerHTML = `
<div class="toast-icon" style="background:${iconColor}; color:var(--bg-body)">
<svg xmlns="http://www.w3.org/2000/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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="toast-content">
<div class="toast-title">${dsTitle}</div>
<div class="toast-msg">${dsMsg}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
async function forceSave() {
try {
await fetch('/api/save-data', { method: 'POST' });
addLog('[INFO] Data save requested');
} catch (e) {
addLog('[ERROR] Save request failed');
}
}
// --- File Manager ---
async function refreshFiles() {
try {
const res = await fetch('/api/files');
const data = await res.json();
allFiles = data.files;
renderFiles();
} catch (e) { console.error(e); }
}
function renderFiles() {
const container = document.getElementById('file-list-body');
const breadcrumb = document.getElementById('breadcrumb');
const btnUpload = document.getElementById('btn-upload');
container.innerHTML = '';
// Breadcrumb
if (currentPath === 'root') {
breadcrumb.innerHTML = '<div class="breadcrumb-item active">Home</div>';
btnUpload.disabled = true;
} else {
const nameMap = {
config: 'Configuration',
music: 'Music',
voice: 'Voice',
beep: 'Beep',
station: 'Station'
};
const name = ds(nameMap[currentPath] || currentPath);
breadcrumb.innerHTML = `
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
<div class="breadcrumb-sep">/</div>
<div class="breadcrumb-item active">${name}</div>
`;
btnUpload.disabled = false;
}
// Content
if (currentPath === 'root') {
const folders = [
{ id: 'config', name: 'Configuration', count: allFiles.filter(f => f.type === 'config').length },
{ id: 'music', name: 'Music', count: allFiles.filter(f => f.type === 'music').length },
{ id: 'voice', name: 'Voice', count: allFiles.filter(f => f.type === 'voice').length },
{ id: 'beep', name: 'Beep', count: allFiles.filter(f => f.type === 'beep').length },
{ id: 'station', name: 'Station', count: allFiles.filter(f => f.type === 'station').length }
];
folders.forEach(f => {
const row = document.createElement('div');
row.className = 'file-row';
row.onclick = () => navigate(f.id);
const dsFolderName = ds(f.name);
const dsCount = String(f.count || 0);
row.innerHTML = `
<div></div>
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${dsFolderName}</span></div>
<div class="file-meta">${dsCount} items</div>
<div></div>
`;
container.appendChild(row);
});
document.getElementById('select-all').disabled = true;
} else {
document.getElementById('select-all').disabled = false;
const files = allFiles.filter(f => f.type === currentPath);
if (files.length === 0) container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">Empty folder</div>';
files.forEach(f => {
const typeLabelMap = {
config: 'Configuration',
music: 'Music',
voice: 'Voice',
beep: 'Beep',
station: 'Station'
};
const row = document.createElement('div');
row.className = 'file-row';
const dsFileName = ds(f.name);
const dsTypeLabel = ds(typeLabelMap[f.type] || f.type || '');
// Click to preview
row.onclick = (e) => {
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
openPreview(f);
}
};
row.innerHTML = `
<div style="display:flex;justify-content:center"><input type="checkbox" class="file-check" value="${f.path}" onchange="updateSelection()"></div>
<div style="display:flex;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${dsFileName}</span></div>
<div class="file-meta">${dsTypeLabel}</div>
<div style="text-align:right">
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
</div>
`;
container.appendChild(row);
});
}
updateSelection();
}
function navigate(path) {
currentPath = path;
document.getElementById('select-all').checked = false;
renderFiles();
}
function toggleSelectAll() {
const main = document.getElementById('select-all');
document.querySelectorAll('.file-check').forEach(c => c.checked = main.checked);
updateSelection();
}
function updateSelection() {
const count = document.querySelectorAll('.file-check:checked').length;
const btn = document.getElementById('btn-delete');
const span = document.getElementById('selection-count');
if (count > 0) {
btn.disabled = false;
span.textContent = `${count} selected`;
} else {
btn.disabled = true;
span.textContent = '';
}
}
// --- Actions ---
async function deleteFile(path) {
await fetch(`/api/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
refreshFiles();
}
async function deleteSelected() {
const checks = document.querySelectorAll('.file-check:checked');
const btn = document.getElementById('btn-delete');
btn.textContent = '...';
for (const c of checks) await deleteFile(c.value);
btn.textContent = 'Delete';
refreshFiles();
}
async function deleteStation(name) {
await fetch(`/api/stations/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
async function deleteTrain(id) {
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
function triggerUpload() {
if (currentPath === 'config') document.getElementById('upload-config').click();
else if (currentPath === 'music') document.getElementById('upload-music').click();
else if (currentPath === 'voice') document.getElementById('upload-voice').click();
else if (currentPath === 'beep') document.getElementById('upload-general').click();
else if (currentPath === 'station') document.getElementById('upload-station').click();
}
async function performUpload(field, url, input) {
if (input.files.length === 0) return;
const btn = document.getElementById('btn-upload');
const originalText = btn.textContent;
const total = input.files.length;
btn.disabled = true;
const BATCH_SIZE = 5;
let successCount = 0;
const uploadBatch = async (files) => {
const formData = new FormData();
for (let f of files) formData.append(field, f);
try {
await fetch(url, { method: 'POST', body: formData });
return files.length;
} catch (e) { return 0; }
};
if (field === 'listFile') {
btn.textContent = 'Uploading...';
const formData = new FormData();
formData.append(field, input.files[0]);
try { await fetch(url, { method: 'POST', body: formData }); } catch (e) { }
} else {
const fileArray = Array.from(input.files);
for (let i = 0; i < total; i += BATCH_SIZE) {
const batch = fileArray.slice(i, i + BATCH_SIZE);
const progress = Math.min(Math.round(((i + batch.length) / total) * 100), 100);
btn.textContent = `Uploading ${progress}%`;
await uploadBatch(batch);
}
}
btn.textContent = originalText;
btn.disabled = false;
input.value = '';
refreshFiles();
}
// --- Preview & Edit ---
let currentPreviewFile = null;
let audioInstance = null;
async function openPreview(file) {
currentPreviewFile = file;
const modal = document.getElementById('preview-modal');
const title = document.getElementById('modal-title');
const content = document.getElementById('modal-content');
const saveBtn = document.getElementById('btn-save');
title.textContent = file.name;
content.innerHTML = '<div style="text-align:center; color:var(--text-muted)">Loading...</div>';
modal.classList.add('active');
if (file.name.endsWith('.txt')) {
saveBtn.style.display = 'block';
try {
const res = await fetch(`/api/files/content?path=${encodeURIComponent(file.path)}`);
const data = await res.json();
if (data.success) {
content.innerHTML = `<textarea class="editor" id="file-editor" spellcheck="false">${data.content}</textarea>`;
} else {
content.innerHTML = `<div style="color:var(--danger)">Error: ${data.message}</div>`;
}
} catch (e) {
content.innerHTML = `<div style="color:var(--danger)">Failed to load content</div>`;
}
} else if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/i.test(file.name)) {
saveBtn.style.display = 'none';
content.innerHTML = `
<div class="audio-player">
<button class="play-btn" id="audio-play-btn">${ICONS.play}</button>
<div class="progress-container" id="audio-progress">
<div class="progress-bar" id="audio-bar"></div>
</div>
<span class="time-display" id="audio-time">0:00 / 0:00</span>
</div>
`;
initAudioPlayer(file.path);
} else {
saveBtn.style.display = 'none';
content.innerHTML = `
<div style="text-align:center; padding: 40px; color:var(--text-muted)">
<div>Preview not available for this file type</div>
${file.name.endsWith('.dfpwm') ? '<div style="margin-top:8px; font-size:0.8rem">DFPWM requires in-game player</div>' : ''}
</div>
`;
}
}
function initAudioPlayer(path) {
if (audioInstance) { audioInstance.pause(); audioInstance = null; }
let source = '/' + path;
if (path.startsWith('Audio/music/')) {
source = `/media/audio/music/${encodeURIComponent(path.slice('Audio/music/'.length))}`;
} else if (path.startsWith('Audio/voice/')) {
source = `/media/audio/voice/${encodeURIComponent(path.slice('Audio/voice/'.length))}`;
} else if (path.startsWith('Audio/beep/')) {
source = `/media/audio/beep/${encodeURIComponent(path.slice('Audio/beep/'.length))}`;
} else if (path.startsWith('Audio/general/')) {
source = `/media/audio/general/${encodeURIComponent(path.slice('Audio/general/'.length))}`;
} else if (path.startsWith('Audio/station/')) {
source = `/media/audio/station/${encodeURIComponent(path.slice('Audio/station/'.length))}`;
}
const audio = new Audio(source);
audioInstance = audio;
const btn = document.getElementById('audio-play-btn');
const progress = document.getElementById('audio-progress');
const bar = document.getElementById('audio-bar');
const timeDisplay = document.getElementById('audio-time');
const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
audio.addEventListener('loadedmetadata', () => {
timeDisplay.textContent = `0:00 / ${formatTime(audio.duration)}`;
});
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
bar.style.width = `${pct}%`;
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
});
audio.addEventListener('ended', () => {
btn.innerHTML = ICONS.play;
bar.style.width = '0%';
});
btn.addEventListener('click', () => {
if (audio.paused) {
audio.play();
btn.innerHTML = ICONS.pause;
} else {
audio.pause();
btn.innerHTML = ICONS.play;
}
});
progress.addEventListener('click', (e) => {
const rect = progress.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = x / rect.width;
audio.currentTime = pct * audio.duration;
});
}
async function saveFile() {
if (!currentPreviewFile || !currentPreviewFile.name.endsWith('.txt')) return;
const editor = document.getElementById('file-editor');
if (!editor) return;
const btn = document.getElementById('btn-save');
const originalText = btn.textContent;
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: currentPreviewFile.path,
content: editor.value
})
});
const data = await res.json();
if (data.success) {
btn.textContent = 'Saved';
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 1000);
} else {
alert('Save failed: ' + data.message);
btn.textContent = originalText;
btn.disabled = false;
}
} catch (e) {
alert('Save error');
btn.textContent = originalText;
btn.disabled = false;
}
}
function closeModal() {
document.getElementById('preview-modal').classList.remove('active');
if (audioInstance) {
audioInstance.pause();
audioInstance = null;
}
currentPreviewFile = null;
}
// Init
refreshFiles();
fetchStationUpdate();
function renderStationUpdate(info) {
const el = document.getElementById('current-version');
const localVersion = ds(info?.localVersion || '-');
const targetVersion = ds(info?.targetVersion || '');
const publishedAt = info?.publishedAt ? ds(new Date(info.publishedAt).toLocaleString()) : '';
const remoteVersionHtml = info?.remoteVersion
? `<div>Gitea最新:${ds(info.remoteVersion)}</div>`
: '';
el.innerHTML = `
<div>服务器脚本:${localVersion}</div>
${remoteVersionHtml}
<div>已发布版本:${targetVersion}</div>
<div style="margin-top:4px;color:var(--text-muted)">发布时间:${publishedAt}</div>
`;
}
async function fetchStationUpdate() {
try {
const res = await fetch('/api/station-update');
const data = await res.json();
currentStationUpdate = data;
try {
const remoteRes = await fetch('/api/remote-version');
const remoteData = await remoteRes.json();
if (remoteData.success) {
data.remoteVersion = String(remoteData.version || '');
} else {
data.remoteVersion = '';
}
} catch (e) {
data.remoteVersion = '';
}
renderStationUpdate(data);
} catch (e) {
document.getElementById('current-version').textContent = 'Error';
}
}
async function publishStationUpdate() {
if (!confirm('发布后,所有在线主机会立即下载最新 startup 并自动重启;离线主机会在下次加载时立即更新。确定继续吗?')) return;
const btn = document.getElementById('publish-update-btn');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '发布中...';
try {
const res = await fetch('/api/station-updates/publish', { method: 'POST' });
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Publish failed');
currentStationUpdate = data;
renderStationUpdate(data);
showToast('更新已发布', `目标版本 ${data.targetVersion || data.localVersion},已通知 ${data.notifiedStations || 0}/${data.onlineStations || 0} 台在线主机。`);
} catch (e) {
alert(`发布失败:${e.message || e}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// --- Resizers ---
const resizerV = document.getElementById('resizer-v');
const resizerH = document.getElementById('resizer-h');
const mainArea = document.getElementById('main-area');
const body = document.body;
// Vertical Resizer (Sidebar width)
let isResizingV = false;
resizerV.addEventListener('mousedown', (e) => {
isResizingV = true;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
// Horizontal Resizer (Console height)
let isResizingH = false;
resizerH.addEventListener('mousedown', (e) => {
isResizingH = true;
document.body.style.cursor = 'row-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (isResizingV) {
// Constrain width (min 200px, max 600px)
const newWidth = Math.max(200, Math.min(e.clientX, 600));
mainArea.style.gridTemplateColumns = `${newWidth}px 4px 1fr`;
}
if (isResizingH) {
// Constrain console height (min 100px, max 80vh)
const containerHeight = window.innerHeight;
const newConsoleHeight = containerHeight - e.clientY;
const clampedHeight = Math.max(100, Math.min(newConsoleHeight, containerHeight - 200));
body.style.gridTemplateRows = `1fr 4px ${clampedHeight}px`; // Add 4px for splitter
}
});
window.addEventListener('mouseup', () => {
if (isResizingV || isResizingH) {
isResizingV = false;
isResizingH = false;
document.body.style.cursor = 'default';
}
});
</script>
</body>
</html>