需求:
1. 客户端与网页端所有展示到屏幕上的内容,去掉 ???、Unknown、未知版本、未选择、未合成、暂无在线 等占位问号/疑问式字符;
2. 网页端所有页面统一 cablemanager.html 那套风格(色板、硬边边框、圆角 6/8、顶栏渐变字/下边框分隔、shadow、body 径向渐变);
3. 补齐 <select>/option 下拉样式(appearance none、内联 SVG 箭头、padding-right 留位、ring focus、option 背景与主色一致),不再沿用系统默认。
改动:
1. station_broadcast.lua:
- 新增 Utils.sanitizeDisplay:统一把任意显示字符串内的 ? 全部剥离并 trim 空白,空串返回 '-'
- Display.render 里 truncL / truncR 从直接 tostring 改为走 sanitizeDisplay,保证所有监视器内容 0 问号
- Display.render 底部 3 条日志(drawLeft)和 Display.renderTerminal 顶部日志全部走 sanitizeDisplay
2. CloudServer/public/:
- index.html:
* :root 色板对齐 cablemanager(--bg-panel #121214、--text-primary #f4f4f5、--text-muted #a1a1aa、加 --bg-input/--ring-color、radius 8px)
* body 改 radial-gradient 背景
* .btn / .btn-primary / .btn-danger 统一成 cablemanager 同款尺寸(10x14、radius 6、font-weight 500/600)
* 新增 input[type=text|number|password] + textarea + select + option 完整样式组(cablemanager 同款)
* 清理 display 层的 Unknown/未知/未发布:
- stationVersion || '未知版本' 改为 || ''(空版本号直接省略,不写未知)
- seqHtml fallback 从 'Unknown sequence' 改为 ''
- localVersion 从 '未知' 改为 '-',targetVersion/publishedAt 从 '未发布' 改为 ''(空即省略)
- infoboard.html:
* :root 对齐 cablemanager,加 --bg-input、--ring-color、加 body radial-gradient
* 车次目的地 fallback 从 '未知终点' 改为 ''(没终点就空白)
- music.html:
* :root / body / layout-wrapper / topbar / title / btn / panel / card-title / badge / input+select+textarea 全对齐 cablemanager
* track_badge 默认占位从 '未选择' 改空(badge:empty 直接不显示)
* 空站台列表占位从 '暂无在线/已注册站台' 改为 '-'
* 补了完整 scrollbar 样式
* 最外层 body 内加 .layout-wrapper(对齐 cablemanager max-width 1280 + 居中)
- tempbroadcast.html:
* 所有风格同步 music.html(与 cablemanager 一致)
* build_badge 默认 '未合成' 改为空,失败分支也清空(只在合成成功/合成中写入),同时 .badge:empty {display:none}
* 空站台列表占位 '-'
* 补 select 下拉外观样式
客户端 bump v1.0.105 -> v1.0.106。
server.js node -c 通过;所有修改 HTML 仅改 style 段与少量占位文本,不影响 API 契约。
1880 lines
68 KiB
HTML
1880 lines
68 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>
|
|
// --- 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 = st.version || '';
|
|
const targetVersion = currentStationUpdate?.targetVersion || '';
|
|
const needsUpdate = !!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-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
div.innerHTML = `
|
|
<div class="status-main">
|
|
<div class="item-info">
|
|
<div class="item-title">
|
|
${st.name}
|
|
${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">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''} · ${stationVersion}</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></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 || '-';
|
|
|
|
div.innerHTML = `
|
|
<div class="status-main">
|
|
<div class="item-info">
|
|
<div class="item-title">${tr.trainId}</div>
|
|
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</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 cardId = `stream-card-${station.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
|
|
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 = seq ? 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;">${s}</span>`).join('') : '';
|
|
|
|
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;">${station}</div>
|
|
<div class="status-dot dot-broadcasting"></div>
|
|
</div>
|
|
${hlsUrl ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${hlsUrl}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${hlsUrl}</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*/, '');
|
|
|
|
div.innerHTML = `<div class="log-time">${time}</div><div class="log-msg"><span class="${typeClass}">></span> ${content}</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';
|
|
|
|
// 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">${title}</div>
|
|
<div class="toast-msg">${message}</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 = 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);
|
|
row.innerHTML = `
|
|
<div></div>
|
|
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
|
|
<div class="file-meta">${f.count} 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';
|
|
// 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">${f.name}</span></div>
|
|
<div class="file-meta">${typeLabelMap[f.type] || f.type}</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 = info?.localVersion || '-';
|
|
const targetVersion = info?.targetVersion || '';
|
|
const publishedAt = info?.publishedAt ? new Date(info.publishedAt).toLocaleString() : '';
|
|
const remoteVersionHtml = info?.remoteVersion ? `<div>Gitea最新:${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 = 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>
|