Compare commits
73
Commits
52a643a868
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c69740470 | ||
|
|
9c5d6e9659 | ||
|
|
dc8f94ce22 | ||
|
|
8c9c72fcba | ||
|
|
0335e245e1 | ||
|
|
1179130656 | ||
|
|
a58632272e | ||
|
|
fb17de6493 | ||
|
|
a3ff8664ee | ||
|
|
5e466f4553 | ||
|
|
4c049050ce | ||
|
|
e0a1252db6 | ||
|
|
b2c415f253 | ||
|
|
a6d9b6c418 | ||
|
|
5c462a4d09 | ||
|
|
fc66e43379 | ||
|
|
c9d8994942 | ||
|
|
4c7d94f2c9 | ||
|
|
6ac05d3a84 | ||
|
|
55f0312314 | ||
|
|
849b43af39 | ||
|
|
e9819d4894 | ||
|
|
66683f6d13 | ||
|
|
50746ce766 | ||
|
|
0de59531f9 | ||
|
|
926db04a3e | ||
|
|
ae213a84a1 | ||
|
|
cd5b1d47b0 | ||
|
|
716a23cad5 | ||
|
|
ae96c86288 | ||
|
|
17b03ce0f7 | ||
|
|
32b2c8ce9d | ||
|
|
34d9ed00d6 | ||
|
|
82a4ecd3e8 | ||
|
|
1b958c25df | ||
|
|
1d99634957 | ||
|
|
af00d57e68 | ||
|
|
4b972c3ae6 | ||
|
|
71f6800a2a | ||
|
|
b74eb252e3 | ||
|
|
298308b7f3 | ||
|
|
6b1a531b77 | ||
|
|
2eddc9fefe | ||
|
|
51968cc825 | ||
|
|
8f70c1f1d3 | ||
|
|
d8eee041c8 | ||
|
|
01ac488aef | ||
|
|
c86fca8449 | ||
|
|
6398146ee6 | ||
|
|
86e58a5c59 | ||
|
|
aa10f0ebe1 | ||
|
|
effcd30291 | ||
|
|
3c25040a84 | ||
|
|
fdbcfa3b9b | ||
|
|
87fda31485 | ||
|
|
f846004091 | ||
|
|
439ed9ea3c | ||
|
|
eb018dbae9 | ||
|
|
619d4e9a20 | ||
|
|
3c71d64f79 | ||
|
|
a36b21cfaf | ||
|
|
101d392840 | ||
|
|
c8681417a8 | ||
|
|
bf1895ebc5 | ||
|
|
394f81b9fa | ||
|
|
5fb81e3998 | ||
|
|
e89b82a8b4 | ||
|
|
5dbd3d667f | ||
|
|
6964e7a47d | ||
|
|
59865138c7 | ||
|
|
b2888c6c88 | ||
|
|
aef63ca444 | ||
|
|
10d15dfddc |
@@ -7,3 +7,8 @@ CloudServer/server-data.json
|
|||||||
Audio/general/
|
Audio/general/
|
||||||
Audio/music/
|
Audio/music/
|
||||||
Audio/station/
|
Audio/station/
|
||||||
|
tmp_vercel_proxy/
|
||||||
|
nbt_dump.txt
|
||||||
|
test.lua
|
||||||
|
temp-broadcast/
|
||||||
|
debug-upcoming-data-missing.md
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,4 @@
|
|||||||
/*
|
|
||||||
* @Author: HenryDu8133 813367384@qq.com
|
|
||||||
* @Date: 2026-02-24 14:42:13
|
|
||||||
* @LastEditors: HenryDu8133 813367384@qq.com
|
|
||||||
* @LastEditTime: 2026-06-07 02:05:41
|
|
||||||
* @FilePath: \StationBroadcastDisplayCN\CloudServer\ecosystem.config.js
|
|
||||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps : [{
|
apps : [{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -725,6 +725,14 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
const TTS_MODEL = "s2.1-pro-free";
|
const TTS_MODEL = "s2.1-pro-free";
|
||||||
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
|
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
|
||||||
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
|
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
|
||||||
@@ -826,8 +834,8 @@
|
|||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${ds(title)}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted)">${ds(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -940,11 +948,15 @@
|
|||||||
item.style.alignItems = 'stretch';
|
item.style.alignItems = 'stretch';
|
||||||
item.style.gap = '8px';
|
item.style.gap = '8px';
|
||||||
|
|
||||||
|
const dsCode = ds(task.code);
|
||||||
|
const dsZh = ds(task.zh || '');
|
||||||
|
const dsJa = ds(task.en || '');
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||||
<div style="display:flex; flex-direction:column; gap:2px;">
|
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||||
<span style="font-weight:600; color:var(--accent)">${task.code}</span>
|
<span style="font-weight:600; color:var(--accent)">${dsCode}</span>
|
||||||
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${task.zh} | JA: ${task.en}</span>
|
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${dsZh} | JA: ${dsJa}</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
|
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1085,12 +1097,6 @@
|
|||||||
const task = batchTasks[i];
|
const task = batchTasks[i];
|
||||||
if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones
|
if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones
|
||||||
|
|
||||||
// Replace slashes with dashes to ensure filesystem compatibility
|
|
||||||
if (task.code.includes('/')) {
|
|
||||||
console.log(`Replacing slashes in batch code: ${task.code}`);
|
|
||||||
task.code = task.code.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusSpan = document.getElementById(`batch-status-span-${i}`);
|
const statusSpan = document.getElementById(`batch-status-span-${i}`);
|
||||||
statusSpan.textContent = 'Importing...';
|
statusSpan.textContent = 'Importing...';
|
||||||
statusSpan.style.color = 'var(--warning)';
|
statusSpan.style.color = 'var(--warning)';
|
||||||
@@ -1190,12 +1196,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace slashes with dashes to ensure filesystem compatibility
|
|
||||||
if (code.includes('/')) {
|
|
||||||
console.log(`Replacing slashes in code: ${code}`);
|
|
||||||
code = code.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zhTempFile || !jaTempFile) {
|
if (!zhTempFile || !jaTempFile) {
|
||||||
showToast("Error", "No audio generated to import");
|
showToast("Error", "No audio generated to import");
|
||||||
return;
|
return;
|
||||||
@@ -1318,10 +1318,11 @@
|
|||||||
div.className = 'list-item';
|
div.className = 'list-item';
|
||||||
div.style.gridTemplateColumns = '100px 1fr auto';
|
div.style.gridTemplateColumns = '100px 1fr auto';
|
||||||
|
|
||||||
const tagsHtml = item.files.map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${f}</span>`).join('');
|
const dsCode = ds(item.code);
|
||||||
|
const tagsHtml = (item.files || []).map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${ds(f)}</span>`).join('');
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div style="font-family:var(--font-mono); font-weight:600;">${item.code}</div>
|
<div style="font-family:var(--font-mono); font-weight:600;">${dsCode}</div>
|
||||||
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
|
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
|
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
|
||||||
|
|||||||
+214
-53
@@ -10,33 +10,25 @@
|
|||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Ultra-Dark / Minimalist Theme */
|
/* Ultra-Dark / Minimalist Theme (aligned with cablemanager) */
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
/* Zinc-950 */
|
--bg-panel: #121214;
|
||||||
--bg-panel: #18181b;
|
--bg-input: #09090b;
|
||||||
/* Zinc-900 */
|
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
/* Zinc-800 */
|
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
/* Zinc-800 */
|
|
||||||
|
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
/* Zinc-200 */
|
--text-muted: #a1a1aa;
|
||||||
--text-muted: #71717a;
|
|
||||||
/* Zinc-500 */
|
|
||||||
|
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
/* Zinc-50 */
|
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
/* Red-500 */
|
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
/* Emerald-500 */
|
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
/* Amber-500 */
|
|
||||||
|
|
||||||
--radius: 6px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -45,7 +37,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
background-color: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -299,14 +291,18 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 6px 12px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
@@ -322,15 +318,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: #fff;
|
background: #d4d4d8;
|
||||||
border-color: #fff;
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.3;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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 {
|
.btn-icon {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -699,6 +705,61 @@
|
|||||||
display: none;
|
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 */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -929,14 +990,17 @@
|
|||||||
|
|
||||||
<!-- Sidebar: Status -->
|
<!-- Sidebar: Status -->
|
||||||
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
||||||
<!-- Version Control (Hidden since we don't use it anymore) -->
|
<!-- Station Update -->
|
||||||
<div class="version-panel" style="display:none;">
|
<div class="version-panel">
|
||||||
<div class="version-panel-header">
|
<div class="version-panel-header">
|
||||||
<span>Audio Version</span>
|
<span>Station Update</span>
|
||||||
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="version-display" id="current-version">Loading...</div>
|
<div class="version-display" id="current-version">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -977,7 +1041,7 @@
|
|||||||
<span id="selection-count"
|
<span id="selection-count"
|
||||||
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
|
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>
|
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
|
||||||
<a href="/infoboard.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Full Info Board</a>
|
<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="/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="/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="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
|
||||||
@@ -1055,6 +1119,15 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// --- Display sanitizer: 强制剥除所有 ? 类字符,保证 UI 零问号 ---
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Icons (SVG) ---
|
// --- Icons (SVG) ---
|
||||||
const ICONS = {
|
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>`,
|
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>`,
|
||||||
@@ -1072,6 +1145,7 @@
|
|||||||
let currentPath = 'root';
|
let currentPath = 'root';
|
||||||
let stationCodeMap = {};
|
let stationCodeMap = {};
|
||||||
let broadcastingStations = new Set();
|
let broadcastingStations = new Set();
|
||||||
|
let currentStationUpdate = null;
|
||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
const connStatus = document.getElementById('conn-status');
|
const connStatus = document.getElementById('conn-status');
|
||||||
@@ -1118,6 +1192,11 @@
|
|||||||
|
|
||||||
// --- Renderers ---
|
// --- Renderers ---
|
||||||
function updateState(state) {
|
function updateState(state) {
|
||||||
|
if (state.stationUpdate) {
|
||||||
|
currentStationUpdate = state.stationUpdate;
|
||||||
|
renderStationUpdate(state.stationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.stations) {
|
if (state.stations) {
|
||||||
const list = document.getElementById('station-list');
|
const list = document.getElementById('station-list');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
@@ -1136,20 +1215,31 @@
|
|||||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||||
const stationTarget = st.target || st.name;
|
const stationTarget = st.target || st.name;
|
||||||
const memberCount = Number(st.memberCount || 1);
|
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');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
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.innerHTML = `
|
||||||
<div class="status-main">
|
<div class="status-main">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
${st.name}
|
${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>' : ''}
|
${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>
|
||||||
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}</div>
|
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - Platform ${dsPlatform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}${dsVersion ? ' · ' + dsVersion : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<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>
|
<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>
|
<button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1168,12 +1258,15 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
|
const dsTrainId = ds(tr.trainId);
|
||||||
|
const dsStatus = ds(tr.status || '');
|
||||||
|
const dsCurCode = ds(currentStCode);
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="status-main">
|
<div class="status-main">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId}</div>
|
<div class="item-title">${dsTrainId}</div>
|
||||||
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
|
<div class="item-sub">${dsCurCode} <span style="margin-left:8px;color:var(--text-muted)">${dsStatus}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
@@ -1200,7 +1293,9 @@
|
|||||||
|
|
||||||
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
||||||
const container = document.getElementById('radio-streams-container');
|
const container = document.getElementById('radio-streams-container');
|
||||||
const cardId = `stream-card-${station.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
const dsStation = ds(station || '');
|
||||||
|
const stationKey = String(station || '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
const cardId = `stream-card-${stationKey}`;
|
||||||
|
|
||||||
if (isStarting) {
|
if (isStarting) {
|
||||||
// Clear empty state
|
// Clear empty state
|
||||||
@@ -1216,15 +1311,20 @@
|
|||||||
container.appendChild(card);
|
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('') : 'Unknown sequence';
|
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 = `
|
card.innerHTML = `
|
||||||
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
|
<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="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div style="font-weight:600; font-size:1.1rem;">${station}</div>
|
<div style="font-weight:600; font-size:1.1rem;">${dsStation}</div>
|
||||||
<div class="status-dot dot-broadcasting"></div>
|
<div class="status-dot dot-broadcasting"></div>
|
||||||
</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>` : ''}
|
${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>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</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;">
|
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
|
||||||
@@ -1288,8 +1388,10 @@
|
|||||||
else typeClass = 'type-info';
|
else typeClass = 'type-info';
|
||||||
|
|
||||||
content = content.replace(/^\[.*?\]\s*/, '');
|
content = content.replace(/^\[.*?\]\s*/, '');
|
||||||
|
const dsTime = ds(time);
|
||||||
|
const dsContent = ds(content);
|
||||||
|
|
||||||
div.innerHTML = `<div class="log-time">${time}</div><div class="log-msg"><span class="${typeClass}">></span> ${content}</div>`;
|
div.innerHTML = `<div class="log-time">${dsTime}</div><div class="log-msg"><span class="${typeClass}">></span> ${dsContent}</div>`;
|
||||||
container.prepend(div);
|
container.prepend(div);
|
||||||
if (container.children.length > 100) container.lastChild.remove();
|
if (container.children.length > 100) container.lastChild.remove();
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1401,9 @@
|
|||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
|
|
||||||
|
const dsTitle = ds(title || '');
|
||||||
|
const dsMsg = ds(message || '');
|
||||||
|
|
||||||
// Icon based on title/message
|
// Icon based on title/message
|
||||||
let iconColor = 'var(--text-primary)';
|
let iconColor = 'var(--text-primary)';
|
||||||
if (title.includes('Arrival')) iconColor = 'var(--success)';
|
if (title.includes('Arrival')) iconColor = 'var(--success)';
|
||||||
@@ -1309,8 +1414,8 @@
|
|||||||
<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>
|
<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>
|
||||||
<div class="toast-content">
|
<div class="toast-content">
|
||||||
<div class="toast-title">${title}</div>
|
<div class="toast-title">${dsTitle}</div>
|
||||||
<div class="toast-msg">${message}</div>
|
<div class="toast-msg">${dsMsg}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1360,7 +1465,7 @@
|
|||||||
beep: 'Beep',
|
beep: 'Beep',
|
||||||
station: 'Station'
|
station: 'Station'
|
||||||
};
|
};
|
||||||
const name = nameMap[currentPath] || currentPath;
|
const name = ds(nameMap[currentPath] || currentPath);
|
||||||
breadcrumb.innerHTML = `
|
breadcrumb.innerHTML = `
|
||||||
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
||||||
<div class="breadcrumb-sep">/</div>
|
<div class="breadcrumb-sep">/</div>
|
||||||
@@ -1383,10 +1488,12 @@
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'file-row';
|
row.className = 'file-row';
|
||||||
row.onclick = () => navigate(f.id);
|
row.onclick = () => navigate(f.id);
|
||||||
|
const dsFolderName = ds(f.name);
|
||||||
|
const dsCount = String(f.count || 0);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div></div>
|
<div></div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
|
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${dsFolderName}</span></div>
|
||||||
<div class="file-meta">${f.count} items</div>
|
<div class="file-meta">${dsCount} items</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
@@ -1408,6 +1515,8 @@
|
|||||||
};
|
};
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'file-row';
|
row.className = 'file-row';
|
||||||
|
const dsFileName = ds(f.name);
|
||||||
|
const dsTypeLabel = ds(typeLabelMap[f.type] || f.type || '');
|
||||||
// Click to preview
|
// Click to preview
|
||||||
row.onclick = (e) => {
|
row.onclick = (e) => {
|
||||||
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
||||||
@@ -1416,8 +1525,8 @@
|
|||||||
};
|
};
|
||||||
row.innerHTML = `
|
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;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 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">${typeLabelMap[f.type] || f.type}</div>
|
<div class="file-meta">${dsTypeLabel}</div>
|
||||||
<div style="text-align:right">
|
<div style="text-align:right">
|
||||||
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1688,20 +1797,72 @@
|
|||||||
|
|
||||||
// Init
|
// Init
|
||||||
refreshFiles();
|
refreshFiles();
|
||||||
fetchVersion();
|
fetchStationUpdate();
|
||||||
|
|
||||||
async function fetchVersion() {
|
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 {
|
try {
|
||||||
const res = await fetch('/api/version');
|
const res = await fetch('/api/station-update');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.version) {
|
currentStationUpdate = data;
|
||||||
document.getElementById('current-version').textContent = data.version;
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
document.getElementById('current-version').textContent = 'Error';
|
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 ---
|
// --- Resizers ---
|
||||||
const resizerV = document.getElementById('resizer-v');
|
const resizerV = document.getElementById('resizer-v');
|
||||||
const resizerH = document.getElementById('resizer-h');
|
const resizerH = document.getElementById('resizer-h');
|
||||||
|
|||||||
@@ -10,18 +10,20 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
background-color: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -356,6 +358,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Icons (SVG) ---
|
// --- Icons (SVG) ---
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
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>`,
|
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>`,
|
||||||
@@ -441,7 +451,7 @@
|
|||||||
icon = ICONS.broadcast;
|
icon = ICONS.broadcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageLog.unshift({ time, content: msg, type, icon, important });
|
messageLog.unshift({ time: ds(time), content: ds(msg), type, icon, important });
|
||||||
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
|
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
}
|
}
|
||||||
@@ -484,18 +494,22 @@
|
|||||||
const isBroadcasting = broadcastingStations.has(st.name);
|
const isBroadcasting = broadcastingStations.has(st.name);
|
||||||
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
||||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||||
|
const dsName = ds(st.name);
|
||||||
|
const dsCode = ds(st.code || '');
|
||||||
|
const dsPlatform = st.platform ? ds(st.platform) : '';
|
||||||
|
const stationKey = String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
${st.name}
|
${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>' : ''}
|
${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>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-sub">${st.code}${st.platform ? ` - 站台 ${st.platform}` : ''}</div>
|
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - 站台 ${dsPlatform}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
|
<div class="status-dot ${dotClass}" id="station-dot-${stationKey}" data-online="${isOnline}" data-name="${dsName}"></div>
|
||||||
`;
|
`;
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -517,13 +531,18 @@
|
|||||||
|
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
||||||
|
const dsTrainId = ds(tr.trainId);
|
||||||
|
const dsDest = ds(tr.destination || '');
|
||||||
|
const dsCurCode = ds(currentStCode);
|
||||||
|
const rawStatus = tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中');
|
||||||
|
const dsStatus = ds(rawStatus);
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div style="width:100%">
|
<div style="width:100%">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
|
<div class="item-title">${dsTrainId}${dsDest && dsDest !== '-' ? ' <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">' + dsDest + '</span>' : ''}</div>
|
||||||
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中')}</span></div>
|
<div class="item-sub">${dsCurCode} <span style="color:${statusColor}; margin-left:8px;">${dsStatus}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -452,6 +452,14 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
|
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
|
||||||
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||||
@@ -471,8 +479,8 @@
|
|||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${ds(title)}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted)">${ds(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -539,7 +547,8 @@
|
|||||||
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
|
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
|
||||||
availableFiles.forEach(f => {
|
availableFiles.forEach(f => {
|
||||||
const isSelected = selectedFileValue === f ? 'selected' : '';
|
const isSelected = selectedFileValue === f ? 'selected' : '';
|
||||||
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
|
const dsF = ds(f);
|
||||||
|
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${dsF}')">${dsF}</div>`;
|
||||||
});
|
});
|
||||||
optionsContainer.innerHTML = html;
|
optionsContainer.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -576,9 +585,10 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'list-item';
|
div.className = 'list-item';
|
||||||
|
|
||||||
const tagsHtml = (item.files || []).map(f => `<span class="tag">${f}</span>`).join(' ');
|
const dsCode = ds(item.code);
|
||||||
|
const tagsHtml = (item.files || []).map(f => `<span class="tag">${ds(f)}</span>`).join(' ');
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="code">${item.code}</div>
|
<div class="code">${dsCode}</div>
|
||||||
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
|
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
|
||||||
<div style="display:flex; gap:6px;">
|
<div style="display:flex; gap:6px;">
|
||||||
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
|
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
|
||||||
@@ -636,7 +646,7 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'edit-file-item';
|
div.className = 'edit-file-item';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="edit-file-name">${file}</div>
|
<div class="edit-file-name">${ds(file)}</div>
|
||||||
<div class="edit-file-actions">
|
<div class="edit-file-actions">
|
||||||
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
|
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
|
||||||
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
|
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
|
||||||
|
|||||||
+143
-51
@@ -8,58 +8,95 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--radius: 6px;
|
--success: #10b981;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--warning: #f59e0b;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--radius: 8px;
|
||||||
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 30px 20px;
|
||||||
background: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.layout-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
|
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 { background: var(--bg-hover); }
|
.btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--text-primary);
|
||||||
color: #09090b;
|
color: var(--bg-body);
|
||||||
border-color: var(--accent);
|
border-color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #d4d4d8;
|
||||||
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: 1fr 380px;
|
||||||
@@ -69,7 +106,11 @@
|
|||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -77,10 +118,12 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -90,71 +133,102 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
color: var(--text-primary);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 10px 0 6px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted);
|
font-weight: 500;
|
||||||
}
|
|
||||||
select, input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
|
||||||
background: #0f0f12;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
}
|
||||||
|
input[type="text"], select, textarea {
|
||||||
|
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;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
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, textarea:focus, select:focus {
|
||||||
|
border-color: #52525b;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring-color);
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
margin-top: 10px;
|
padding: 12px;
|
||||||
padding: 10px;
|
border-radius: 6px;
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 2.4em;
|
||||||
}
|
}
|
||||||
.stations {
|
.stations {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
.station-toolbar {
|
.station-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.station-item {
|
.station-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--bg-border);
|
border-bottom: 1px solid var(--bg-border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.station-item:last-child { border-bottom: none; }
|
.station-item:last-child { border-bottom: none; }
|
||||||
.station-name { flex: 1; }
|
.station-item:hover { background: var(--bg-hover); }
|
||||||
|
.station-name { flex: 1; color: var(--text-primary); }
|
||||||
.station-meta {
|
.station-meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -163,9 +237,17 @@
|
|||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #52525b; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="layout-wrapper">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="title">音乐播放</div>
|
<div class="title">音乐播放</div>
|
||||||
<div class="row" style="margin-top:0">
|
<div class="row" style="margin-top:0">
|
||||||
@@ -177,7 +259,7 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h3>控制</h3>
|
<h3>控制</h3>
|
||||||
<span class="badge" id="track_badge">未选择</span>
|
<span class="badge" id="track_badge"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</label>
|
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</label>
|
||||||
@@ -219,14 +301,24 @@
|
|||||||
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
document.getElementById("status_box").textContent = String(msg || "");
|
document.getElementById("status_box").textContent = String(msg || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s == null ? "" : s)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === "" ? "-" : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str || "")
|
let v = sanitizeDisplay(str);
|
||||||
|
return v
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
@@ -273,7 +365,7 @@
|
|||||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||||
if (stations.length === 0) {
|
if (stations.length === 0) {
|
||||||
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
|
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">-</div>';
|
||||||
setStatus("站台列表为空");
|
setStatus("站台列表为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,7 +395,7 @@
|
|||||||
function onTrackChange() {
|
function onTrackChange() {
|
||||||
const sel = document.getElementById("track_select");
|
const sel = document.getElementById("track_select");
|
||||||
const v = String(sel.value || "").trim();
|
const v = String(sel.value || "").trim();
|
||||||
document.getElementById("track_badge").textContent = v ? v : "未选择";
|
document.getElementById("track_badge").textContent = v ? v : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTracks() {
|
async function loadTracks() {
|
||||||
|
|||||||
@@ -189,8 +189,17 @@
|
|||||||
return 'idle';
|
return 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s ?? '')
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '')
|
let v = sanitizeDisplay(s);
|
||||||
|
return v
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
|
|||||||
@@ -256,8 +256,17 @@
|
|||||||
let editKey = '';
|
let editKey = '';
|
||||||
let lastData = null;
|
let lastData = null;
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s ?? '')
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '')
|
let v = sanitizeDisplay(s);
|
||||||
|
return v
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
|
|||||||
@@ -8,60 +8,95 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--radius: 6px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 30px 20px;
|
||||||
background: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.layout-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
|
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 { background: var(--bg-hover); }
|
.btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--text-primary);
|
||||||
color: #09090b;
|
color: var(--bg-body);
|
||||||
border-color: var(--accent);
|
border-color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #d4d4d8;
|
||||||
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: 1fr 380px;
|
||||||
@@ -72,29 +107,38 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
color: var(--text-primary);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.badge:empty { display: none; }
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -102,81 +146,117 @@
|
|||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 10px 0 6px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted);
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
textarea, select, input[type="text"] {
|
textarea, select, input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
background: var(--bg-input);
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 140px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
textarea:focus, select:focus, input[type="text"]:focus {
|
||||||
|
border-color: #52525b;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring-color);
|
||||||
}
|
}
|
||||||
textarea { min-height: 140px; resize: vertical; }
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.row .btn { flex: 0 0 auto; }
|
.row .btn { flex: 0 0 auto; }
|
||||||
.hint {
|
.hint {
|
||||||
margin-top: 10px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
margin-top: 10px;
|
padding: 12px;
|
||||||
padding: 10px;
|
border-radius: 6px;
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 2.4em;
|
||||||
}
|
}
|
||||||
.stations {
|
.stations {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
.station-toolbar {
|
.station-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.station-item {
|
.station-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--bg-border);
|
border-bottom: 1px solid var(--bg-border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.station-item:last-child { border-bottom: none; }
|
.station-item:last-child { border-bottom: none; }
|
||||||
.station-name { flex: 1; }
|
.station-item:hover { background: var(--bg-hover); }
|
||||||
|
.station-name { flex: 1; color: var(--text-primary); }
|
||||||
.station-meta {
|
.station-meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
audio { width: 100%; margin-top: 8px; }
|
audio { width: 100%; }
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #52525b; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="layout-wrapper">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="title">临时广播</div>
|
<div class="title">临时广播</div>
|
||||||
<div class="row" style="margin-top:0">
|
<div class="row" style="margin-top:0">
|
||||||
@@ -188,7 +268,7 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h3 style="margin:0;">内容</h3>
|
<h3 style="margin:0;">内容</h3>
|
||||||
<span class="badge" id="build_badge">未合成</span>
|
<span class="badge" id="build_badge"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="tb_text">广播文本(中文)</label>
|
<label for="tb_text">广播文本(中文)</label>
|
||||||
@@ -233,6 +313,7 @@
|
|||||||
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TTS_MODEL = "s2.1-pro-free";
|
const TTS_MODEL = "s2.1-pro-free";
|
||||||
@@ -269,7 +350,7 @@
|
|||||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||||
if (stations.length === 0) {
|
if (stations.length === 0) {
|
||||||
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
|
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">-</div>';
|
||||||
setStatus("站台列表为空");
|
setStatus("站台列表为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,8 +394,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s == null ? "" : s)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === "" ? "-" : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str || "")
|
let v = sanitizeDisplay(str);
|
||||||
|
return v
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
@@ -383,7 +473,7 @@
|
|||||||
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("合成失败: " + (e?.message || String(e)));
|
setStatus("合成失败: " + (e?.message || String(e)));
|
||||||
document.getElementById("build_badge").textContent = "未合成";
|
document.getElementById("build_badge").textContent = "";
|
||||||
} finally {
|
} finally {
|
||||||
document.getElementById("btn_compose").disabled = false;
|
document.getElementById("btn_compose").disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+2017
-361
File diff suppressed because it is too large
Load Diff
Binary file not shown.
-255
@@ -1,255 +0,0 @@
|
|||||||
|
|
||||||
local aukit = require "aukit"
|
|
||||||
|
|
||||||
local speakers = {peripheral.find("speaker")}
|
|
||||||
if #speakers == 0 then error("No speaker attached") end
|
|
||||||
if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
|
|
||||||
|
|
||||||
local path = ...
|
|
||||||
if not path then error("Usage: austream <file/URL> [arguments for PCM/DFPWM]") end
|
|
||||||
local params = {...}
|
|
||||||
table.remove(params, 1) -- Remove path
|
|
||||||
local v = {}
|
|
||||||
if #params > 0 then
|
|
||||||
-- Try to parse as Lua table string first (original behavior)
|
|
||||||
local paramStr = table.concat(params, " ")
|
|
||||||
-- Remove outer braces if they exist to match original austream format
|
|
||||||
paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
|
|
||||||
|
|
||||||
local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
|
|
||||||
if fn then
|
|
||||||
local ok, parsed = pcall(fn)
|
|
||||||
if ok and type(parsed) == "table" then
|
|
||||||
for k, val in pairs(parsed) do v[k] = val end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- If that fails, try manual string matching
|
|
||||||
if not next(v) then
|
|
||||||
local matchStr = table.concat(params, ", ")
|
|
||||||
if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
|
|
||||||
v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
|
|
||||||
end
|
|
||||||
if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
|
|
||||||
if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
|
|
||||||
if matchStr:match("silent%s*=%s*true") then v.silent = true end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if v.volume == nil then v.volume = 3 end
|
|
||||||
if type(v.volume) == "number" then
|
|
||||||
if v.volume < 0 then v.volume = 0 end
|
|
||||||
if v.volume > 3 then v.volume = 3 end
|
|
||||||
end
|
|
||||||
local mono = v.mono or #speakers == 1
|
|
||||||
aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
|
|
||||||
local data
|
|
||||||
if path:match("^https?://") then
|
|
||||||
local handle, err = http.get(path, nil, true)
|
|
||||||
if not handle then error("Could not connect to " .. path .. ": " .. err) end
|
|
||||||
local code = handle.getResponseCode()
|
|
||||||
if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
|
|
||||||
if v.streamData then
|
|
||||||
local closed = false
|
|
||||||
function data()
|
|
||||||
if closed then return nil end
|
|
||||||
local d = handle.read(48000)
|
|
||||||
if not d then handle.close() closed = true return nil end
|
|
||||||
return d
|
|
||||||
end
|
|
||||||
else
|
|
||||||
data = handle.readAll()
|
|
||||||
handle.close()
|
|
||||||
end
|
|
||||||
elseif path:match("^wss?://") then
|
|
||||||
local ws, err = http.websocket(path)
|
|
||||||
if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
|
|
||||||
|
|
||||||
-- Buffer completely locally to avoid any network stutter
|
|
||||||
local fullBuffer = ""
|
|
||||||
local isDone = false
|
|
||||||
|
|
||||||
local function cleanup()
|
|
||||||
if not isDone then
|
|
||||||
isDone = true
|
|
||||||
if ws then ws.close() end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
|
|
||||||
local startWait = os.startTimer(1.0)
|
|
||||||
while true do
|
|
||||||
local event, p1, p2 = os.pullEvent()
|
|
||||||
if event == "websocket_message" and p1 == path then
|
|
||||||
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
||||||
-- Once we get the first message, we can break and start playing
|
|
||||||
break
|
|
||||||
elseif event == "websocket_closed" and p1 == path then
|
|
||||||
cleanup()
|
|
||||||
break
|
|
||||||
elseif event == "timer" and p1 == startWait then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Since WebSocket connection needs to run alongside aukit stream reading,
|
|
||||||
-- we will just use a non-blocking read approach in the data function itself.
|
|
||||||
|
|
||||||
function data(n)
|
|
||||||
n = n or 6000
|
|
||||||
|
|
||||||
if isDone then
|
|
||||||
if #fullBuffer == 0 then return nil end
|
|
||||||
else
|
|
||||||
-- First, read any available messages without blocking
|
|
||||||
-- ws.receive(0) returns immediately. If nil, no message is ready.
|
|
||||||
while true do
|
|
||||||
local msg = ws.receive(0)
|
|
||||||
if msg == nil then break end
|
|
||||||
if type(msg) == "string" then
|
|
||||||
fullBuffer = fullBuffer .. msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- If we still don't have enough data, wait for more
|
|
||||||
-- but only if we know more might be coming (not isDone)
|
|
||||||
if #fullBuffer < n then
|
|
||||||
-- We only wait briefly so we don't block the audio thread too long
|
|
||||||
-- If the server isn't sending data, it means silence or end of stream
|
|
||||||
local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
|
|
||||||
while #fullBuffer < n do
|
|
||||||
local event, p1, p2 = os.pullEvent()
|
|
||||||
if event == "websocket_message" and p1 == path then
|
|
||||||
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
||||||
elseif event == "websocket_closed" and p1 == path then
|
|
||||||
cleanup()
|
|
||||||
break
|
|
||||||
elseif event == "timer" and p1 == timeout then
|
|
||||||
-- We hit timeout, we must return whatever we have to keep playing
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if #fullBuffer == 0 and isDone then return nil end
|
|
||||||
-- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
|
|
||||||
-- This keeps the stream alive without crashing or making noise
|
|
||||||
if #fullBuffer == 0 then return string.rep("\xAA", n) end
|
|
||||||
|
|
||||||
local ret
|
|
||||||
if #fullBuffer >= n then
|
|
||||||
ret = fullBuffer:sub(1, n)
|
|
||||||
fullBuffer = fullBuffer:sub(n + 1)
|
|
||||||
else
|
|
||||||
ret = fullBuffer
|
|
||||||
-- Pad with silence if we don't have enough data to fulfill the request
|
|
||||||
ret = ret .. string.rep("\xAA", n - #ret)
|
|
||||||
fullBuffer = ""
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
-- Note: aukit.stream handles the reading internally
|
|
||||||
-- We can use a table with a read function for custom streaming
|
|
||||||
local obj = {
|
|
||||||
read = function(n)
|
|
||||||
local d = data(n)
|
|
||||||
if not d and isDone then cleanup() end
|
|
||||||
return d
|
|
||||||
end,
|
|
||||||
close = cleanup
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
|
|
||||||
peripheral.find("modem", rednet.open)
|
|
||||||
local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
|
|
||||||
id = tonumber(id)
|
|
||||||
if proto == "" then proto = nil end
|
|
||||||
if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
|
|
||||||
function data()
|
|
||||||
local i, msg
|
|
||||||
repeat i, msg = rednet.receive(proto) until id == -1 or i == id
|
|
||||||
return msg
|
|
||||||
end
|
|
||||||
else
|
|
||||||
path = shell.resolve(...)
|
|
||||||
local file, err = fs.open(path, "rb")
|
|
||||||
if not file then error("Could not open " .. path .. ": " .. err) end
|
|
||||||
if v.streamData then
|
|
||||||
local closed = false
|
|
||||||
function data()
|
|
||||||
if closed then return nil end
|
|
||||||
local d = file.read(48000)
|
|
||||||
if not d then file.close() closed = true return nil end
|
|
||||||
return d
|
|
||||||
end
|
|
||||||
else
|
|
||||||
data = file.readAll()
|
|
||||||
file.close()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
local iter, length
|
|
||||||
if type(v.type) == "string" then v.type = v.type:lower() end
|
|
||||||
|
|
||||||
local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
|
|
||||||
if isDfpwm then
|
|
||||||
if type(data) == "table" and data.read then
|
|
||||||
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
||||||
else
|
|
||||||
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
||||||
end
|
|
||||||
elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
|
|
||||||
elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
|
|
||||||
elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
|
|
||||||
elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
|
|
||||||
else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
|
|
||||||
if length == nil then length = 0 end
|
|
||||||
|
|
||||||
if not v.silent then
|
|
||||||
print("Streaming...")
|
|
||||||
local w = term.getSize()
|
|
||||||
local y = select(2, term.getCursorPos())
|
|
||||||
local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
|
|
||||||
term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
|
|
||||||
aukit.play(iter, function(pos)
|
|
||||||
pos = math.min(pos, 5999)
|
|
||||||
local p = pos / length
|
|
||||||
term.setCursorPos(1, y)
|
|
||||||
|
|
||||||
-- 计算基本时间字符串
|
|
||||||
local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
|
|
||||||
local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
|
|
||||||
|
|
||||||
if p > 1 then
|
|
||||||
-- 确保空格数量正确
|
|
||||||
local spaces = (" "):rep(w - 12)
|
|
||||||
local text = timeStr .. " " .. spaces .. "--:--"
|
|
||||||
local fgColor = fg:rep(#text)
|
|
||||||
local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
|
|
||||||
-- 确保所有参数长度相等
|
|
||||||
if #text == #fgColor and #text == #bgColor then
|
|
||||||
term.blit(text, fgColor, bgColor)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local barWidth = w - 12
|
|
||||||
local progress = math.floor(barWidth * p)
|
|
||||||
progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
|
|
||||||
|
|
||||||
local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
|
|
||||||
local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
|
|
||||||
local fgColor = fg:rep(#text)
|
|
||||||
local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
|
|
||||||
-- 确保所有参数长度相等
|
|
||||||
if #text == #fgColor and #text == #bgColor then
|
|
||||||
term.blit(text, fgColor, bgColor)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end, v.volume, table.unpack(speakers))
|
|
||||||
print()
|
|
||||||
else
|
|
||||||
aukit.play(iter, function() end, v.volume, table.unpack(speakers))
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
Advanced Display Block NBT Dump
|
||||||
|
Generated: 2026-08-16 14:08:16
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
BlockReader: right
|
||||||
|
|
||||||
|
[Block Registry Name]
|
||||||
|
createrailwaysnavigator:advanced_display_block
|
||||||
|
|
||||||
|
[Block NBT Data]
|
||||||
|
{
|
||||||
|
DisplayId = "createrailwaysnavigator:platform/focus"
|
||||||
|
DisplaySettings = {
|
||||||
|
{
|
||||||
|
BackColor = 0
|
||||||
|
FontColor = -1
|
||||||
|
PlatformWidth = -1
|
||||||
|
PlatformWidthNextStop = -1
|
||||||
|
ShowLineColor = 0
|
||||||
|
ShowTrainMultipleTimes = 1
|
||||||
|
TimeDisplay = 0
|
||||||
|
TrainNameWidth = 12
|
||||||
|
TrainNameWidthNextStop = -1
|
||||||
|
TrainText = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnableCT = 1
|
||||||
|
Filter = "CE-03 2"
|
||||||
|
Glowing = 0
|
||||||
|
IsController = 1
|
||||||
|
Item = {}
|
||||||
|
LastRefreshed = 1303
|
||||||
|
Material = {
|
||||||
|
{
|
||||||
|
Name = "create:copycat_base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Platform = "2"
|
||||||
|
TrainStops = {
|
||||||
|
[1] = {
|
||||||
|
FirstStop = "??"
|
||||||
|
IsFirst = 1
|
||||||
|
IsLast = 1
|
||||||
|
IsNextSectionExcluded = 0
|
||||||
|
IsPrevSectionExcluded = 0
|
||||||
|
State = 1
|
||||||
|
Station = {
|
||||||
|
{
|
||||||
|
Destination = "??"
|
||||||
|
Index = 2
|
||||||
|
RealTimeArrival = 2247
|
||||||
|
RealTimeDeparture = 1347
|
||||||
|
RealTimeStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScheduledArrival = 3869
|
||||||
|
ScheduledDeparture = 4769
|
||||||
|
ScheduledStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainName = "???"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stopovers = {
|
||||||
|
[1] = "????"
|
||||||
|
[2] = "???"
|
||||||
|
}
|
||||||
|
Train = {
|
||||||
|
{
|
||||||
|
Cancelled = 0
|
||||||
|
Carriages = 1
|
||||||
|
Icon = "create:traditional"
|
||||||
|
Id = {
|
||||||
|
[1] = 1934164313
|
||||||
|
[2] = -793689251
|
||||||
|
[3] = -1147623584
|
||||||
|
[4] = 557989055
|
||||||
|
}
|
||||||
|
StateData = {
|
||||||
|
{
|
||||||
|
["0"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["1"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[2] = {
|
||||||
|
FirstStop = "??"
|
||||||
|
IsFirst = 1
|
||||||
|
IsLast = 1
|
||||||
|
IsNextSectionExcluded = 0
|
||||||
|
IsPrevSectionExcluded = 0
|
||||||
|
State = 1
|
||||||
|
Station = {
|
||||||
|
{
|
||||||
|
Destination = "??"
|
||||||
|
Index = 2
|
||||||
|
RealTimeArrival = 6965
|
||||||
|
RealTimeDeparture = 6065
|
||||||
|
RealTimeStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScheduledArrival = 9856
|
||||||
|
ScheduledDeparture = 10756
|
||||||
|
ScheduledStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainName = "???"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stopovers = {
|
||||||
|
[1] = "????"
|
||||||
|
[2] = "???"
|
||||||
|
}
|
||||||
|
Train = {
|
||||||
|
{
|
||||||
|
Cancelled = 0
|
||||||
|
Carriages = 1
|
||||||
|
Icon = "create:traditional"
|
||||||
|
Id = {
|
||||||
|
[1] = 2056578835
|
||||||
|
[2] = 774784604
|
||||||
|
[3] = -1876554799
|
||||||
|
[4] = 2012426381
|
||||||
|
}
|
||||||
|
StateData = {
|
||||||
|
{
|
||||||
|
["0"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["1"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XSize = 1
|
||||||
|
YSize = 1
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
||||||
|
local UPDATE_SCRIPT_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/update_station_broadcast.lua"
|
||||||
|
|
||||||
local function fetch(url)
|
local function fetch(url)
|
||||||
if not http then
|
if not http then
|
||||||
@@ -64,7 +65,8 @@ local function downloadTo(url, path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local targets = {
|
local targets = {
|
||||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
|
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||||
|
{ url = UPDATE_SCRIPT_URL, path = shell.resolve("/update_station_broadcast.lua") }
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t in ipairs(targets) do
|
for _, t in ipairs(targets) do
|
||||||
@@ -76,4 +78,5 @@ for _, t in ipairs(targets) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
print("Install complete.")
|
print("Install complete.")
|
||||||
|
print("Update helper installed as /update_station_broadcast.lua")
|
||||||
print("Run 'reboot' to start StationBroadcast on boot.")
|
print("Run 'reboot' to start StationBroadcast on boot.")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
-- Random Audio Player
|
-- Random Audio Player
|
||||||
-- Simplified version that only plays random audio files
|
-- Simplified version that only plays random audio files
|
||||||
|
|
||||||
@@ -45,13 +46,22 @@ local function playDfpwmFile(path, volume)
|
|||||||
|
|
||||||
if controller and type(controller.playAudioAll) == "function" then
|
if controller and type(controller.playAudioAll) == "function" then
|
||||||
while not controller.playAudioAll(samples, volume) do
|
while not controller.playAudioAll(samples, volume) do
|
||||||
os.pullEvent("speaker_audio_empty")
|
local timerId = os.startTimer(0.05)
|
||||||
|
while true do
|
||||||
|
local ev = { os.pullEvent() }
|
||||||
|
if ev[1] == "timer" and ev[2] == timerId then
|
||||||
|
break
|
||||||
|
elseif ev[1] == "speaker_audio_empty" then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
local pending = {}
|
local pending = {}
|
||||||
for _, sp in ipairs(speakers) do
|
for _, sp in ipairs(speakers) do
|
||||||
pending[peripheral.getName(sp)] = sp
|
pending[peripheral.getName(sp)] = sp
|
||||||
end
|
end
|
||||||
|
local maxAttempts = 20
|
||||||
while next(pending) do
|
while next(pending) do
|
||||||
for name, sp in pairs(pending) do
|
for name, sp in pairs(pending) do
|
||||||
if sp.playAudio(samples, volume) then
|
if sp.playAudio(samples, volume) then
|
||||||
@@ -59,7 +69,19 @@ local function playDfpwmFile(path, volume)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if next(pending) then
|
if next(pending) then
|
||||||
os.pullEvent("speaker_audio_empty")
|
maxAttempts = maxAttempts - 1
|
||||||
|
if maxAttempts <= 0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local timerId = os.startTimer(0.05)
|
||||||
|
while true do
|
||||||
|
local ev = { os.pullEvent() }
|
||||||
|
if ev[1] == "timer" and ev[2] == timerId then
|
||||||
|
break
|
||||||
|
elseif ev[1] == "speaker_audio_empty" then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+2008
-338
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ while true do
|
|||||||
local success, data = pcall(reader.getBlockData)
|
local success, data = pcall(reader.getBlockData)
|
||||||
|
|
||||||
if success and data and data.TrainStops then
|
if success and data and data.TrainStops then
|
||||||
local now = os.epoch("utc") / 50 -- Minecraft ticks (approx)
|
local now = os.epoch("utc") / 50
|
||||||
|
|
||||||
for i, stop in ipairs(data.TrainStops) do
|
for i, stop in ipairs(data.TrainStops) do
|
||||||
local st = stop.Station
|
local st = stop.Station
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
-- BlockReader NBT Dumper for Advanced Display Block
|
||||||
|
-- Reads block NBT via Advanced Peripherals block_reader and writes to txt
|
||||||
|
|
||||||
|
local COLORS = {
|
||||||
|
INFO = colors.white,
|
||||||
|
WARN = colors.yellow,
|
||||||
|
ERROR = colors.red,
|
||||||
|
SUCCESS = colors.green
|
||||||
|
}
|
||||||
|
|
||||||
|
local OUTPUT_FILE = "blockreader_display_nbt.txt"
|
||||||
|
|
||||||
|
local function log(level, message)
|
||||||
|
local timestamp = os.date("%H:%M:%S")
|
||||||
|
local logMessage = string.format("[%s][%s] %s", timestamp, level, message)
|
||||||
|
local color = COLORS[level] or colors.white
|
||||||
|
local oldColor = term.getTextColor()
|
||||||
|
term.setTextColor(color)
|
||||||
|
print(logMessage)
|
||||||
|
term.setTextColor(oldColor)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function serializeValue(value, indent)
|
||||||
|
indent = indent or 0
|
||||||
|
local pad = string.rep(" ", indent)
|
||||||
|
|
||||||
|
local t = type(value)
|
||||||
|
if t == "nil" then
|
||||||
|
return "nil"
|
||||||
|
elseif t == "boolean" then
|
||||||
|
return value and "true" or "false"
|
||||||
|
elseif t == "number" then
|
||||||
|
return tostring(value)
|
||||||
|
elseif t == "string" then
|
||||||
|
local escaped = value:gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
|
||||||
|
return string.format("%q", escaped)
|
||||||
|
elseif t == "table" then
|
||||||
|
local lines = {}
|
||||||
|
local isArray = true
|
||||||
|
local maxIndex = 0
|
||||||
|
for k, v in pairs(value) do
|
||||||
|
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
|
||||||
|
isArray = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if k > maxIndex then maxIndex = k end
|
||||||
|
end
|
||||||
|
if isArray then
|
||||||
|
for i = 1, maxIndex do
|
||||||
|
if value[i] ~= nil then
|
||||||
|
table.insert(lines, pad .. " [" .. tostring(i) .. "] = " .. serializeValue(value[i], indent + 1))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local keys = {}
|
||||||
|
for k, _ in pairs(value) do
|
||||||
|
table.insert(keys, k)
|
||||||
|
end
|
||||||
|
table.sort(keys, function(a, b)
|
||||||
|
local ta, tb = type(a), type(b)
|
||||||
|
if ta == tb then
|
||||||
|
if ta == "number" then return a < b end
|
||||||
|
return tostring(a) < tostring(b)
|
||||||
|
end
|
||||||
|
return ta < tb
|
||||||
|
end)
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
local keyStr
|
||||||
|
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
|
||||||
|
keyStr = k
|
||||||
|
else
|
||||||
|
keyStr = "[" .. serializeValue(k, 0) .. "]"
|
||||||
|
end
|
||||||
|
local v = value[k]
|
||||||
|
if type(v) == "table" and next(v) ~= nil then
|
||||||
|
table.insert(lines, pad .. " " .. keyStr .. " = {")
|
||||||
|
table.insert(lines, serializeValue(v, indent + 1))
|
||||||
|
table.insert(lines, pad .. " }")
|
||||||
|
else
|
||||||
|
table.insert(lines, pad .. " " .. keyStr .. " = " .. serializeValue(v, indent + 1))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #lines == 0 then
|
||||||
|
return "{}"
|
||||||
|
end
|
||||||
|
if isArray then
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
end
|
||||||
|
table.insert(lines, 1, pad .. "{")
|
||||||
|
table.insert(lines, pad .. "}")
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
else
|
||||||
|
return "<" .. t .. ">"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local PERIPHERAL_TYPE_CANDIDATES = {
|
||||||
|
"blockReader",
|
||||||
|
"block_reader",
|
||||||
|
"blockreader",
|
||||||
|
"BlockReader",
|
||||||
|
"advancedperipherals:block_reader",
|
||||||
|
"advancedperipherals:blockReader"
|
||||||
|
}
|
||||||
|
|
||||||
|
local function enumeratePeripherals()
|
||||||
|
local list = {}
|
||||||
|
local names = peripheral.getNames() or {}
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
local entry = {
|
||||||
|
name = name,
|
||||||
|
getType = nil,
|
||||||
|
methodsSorted = nil,
|
||||||
|
getNameCall = nil
|
||||||
|
}
|
||||||
|
local ok, tp = pcall(function() return peripheral.getType(name) end)
|
||||||
|
if ok then entry.getType = tp end
|
||||||
|
if type(entry.getType) == "string" and entry.getType:lower():find("block") and entry.getType:lower():find("reader") then
|
||||||
|
entry.matchHint = "type-name contains block+reader"
|
||||||
|
end
|
||||||
|
local ok2, methods = pcall(function() return peripheral.getMethods(name) end)
|
||||||
|
if ok2 and type(methods) == "table" then
|
||||||
|
local m = {}
|
||||||
|
for _, v in ipairs(methods) do table.insert(m, tostring(v)) end
|
||||||
|
table.sort(m)
|
||||||
|
entry.methodsSorted = m
|
||||||
|
local hasKey = false
|
||||||
|
for _, v in ipairs(m) do
|
||||||
|
if v == "getBlockName" or v == "getBlockState" or v == "getBlockData" or v == "isBlockPresent" then
|
||||||
|
hasKey = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if hasKey then
|
||||||
|
entry.methodsHint = "contains block-reader methods"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ok3, p = pcall(function() return peripheral.wrap(name) end)
|
||||||
|
if ok3 and type(p) == "table" and type(p.getName) == "function" then
|
||||||
|
local ok4, res = pcall(function() return p.getName() end)
|
||||||
|
if ok4 then entry.getNameCall = res end
|
||||||
|
end
|
||||||
|
table.insert(list, entry)
|
||||||
|
end
|
||||||
|
return list
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findBlockReader()
|
||||||
|
for _, candidateType in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||||
|
local ok, found = pcall(function() return { peripheral.find(candidateType) } end)
|
||||||
|
if ok and type(found) == "table" and #found > 0 then
|
||||||
|
for _, dev in ipairs(found) do
|
||||||
|
local ok2, nm = pcall(function() return peripheral.getName(dev) end)
|
||||||
|
if not ok2 then nm = candidateType end
|
||||||
|
if type(dev) == "table" and (dev.getBlockData or dev.getBlockName or dev.getBlockState or dev.isBlockPresent) then
|
||||||
|
return dev, nm
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return found[1], candidateType
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, side in ipairs(rs.getSides()) do
|
||||||
|
if peripheral.isPresent(side) then
|
||||||
|
local ok, tp = pcall(function() return peripheral.getType(side) end)
|
||||||
|
local wrapped = nil
|
||||||
|
local ok2, w = pcall(function() return peripheral.wrap(side) end)
|
||||||
|
if ok2 then wrapped = w end
|
||||||
|
local match = false
|
||||||
|
if ok and type(tp) == "string" then
|
||||||
|
local tpl = tp:lower()
|
||||||
|
for _, cand in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||||
|
if tpl == cand:lower() then match = true; break end
|
||||||
|
end
|
||||||
|
if not match and tpl:find("block") and tpl:find("reader") then match = true end
|
||||||
|
end
|
||||||
|
if not match and type(wrapped) == "table" and (wrapped.getBlockData or wrapped.getBlockName or wrapped.getBlockState or wrapped.isBlockPresent) then
|
||||||
|
match = true
|
||||||
|
end
|
||||||
|
if match then
|
||||||
|
return wrapped, side
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local names = peripheral.getNames() or {}
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
local ok, tp = pcall(function() return peripheral.getType(name) end)
|
||||||
|
local ok2, wrapped = pcall(function() return peripheral.wrap(name) end)
|
||||||
|
if not ok2 then wrapped = nil end
|
||||||
|
local match = false
|
||||||
|
if ok and type(tp) == "string" then
|
||||||
|
local tpl = tp:lower()
|
||||||
|
for _, cand in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||||
|
if tpl == cand:lower() then match = true; break end
|
||||||
|
end
|
||||||
|
if not match and tpl:find("block") and tpl:find("reader") then match = true end
|
||||||
|
end
|
||||||
|
if not match and type(wrapped) == "table" and (wrapped.getBlockData or wrapped.getBlockName or wrapped.getBlockState or wrapped.isBlockPresent) then
|
||||||
|
match = true
|
||||||
|
end
|
||||||
|
if match then
|
||||||
|
return wrapped, name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeToFile(content)
|
||||||
|
local handle = fs.open(OUTPUT_FILE, "w")
|
||||||
|
if not handle then
|
||||||
|
return false, "Cannot open output file"
|
||||||
|
end
|
||||||
|
handle.write(content)
|
||||||
|
handle.close()
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function main()
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
print("=== BlockReader Advanced Display NBT Dumper ===")
|
||||||
|
print("Output file: " .. OUTPUT_FILE)
|
||||||
|
print()
|
||||||
|
|
||||||
|
local output = {}
|
||||||
|
table.insert(output, "Advanced Display Block NBT Dump")
|
||||||
|
table.insert(output, "Generated: " .. os.date("%Y-%m-%d %H:%M:%S"))
|
||||||
|
table.insert(output, string.rep("=", 60))
|
||||||
|
table.insert(output, "")
|
||||||
|
|
||||||
|
log("INFO", "Looking for block_reader peripheral...")
|
||||||
|
local reader, readerName = findBlockReader()
|
||||||
|
if not reader then
|
||||||
|
log("WARN", "block_reader not found by exact type, dumping peripheral diagnostics...")
|
||||||
|
local periList = enumeratePeripherals()
|
||||||
|
table.insert(output, "[Peripheral Diagnostics]")
|
||||||
|
table.insert(output, " Connected peripherals count: " .. tostring(#periList))
|
||||||
|
table.insert(output, "")
|
||||||
|
if #periList == 0 then
|
||||||
|
table.insert(output, " (no peripherals found via peripheral.getNames())")
|
||||||
|
table.insert(output, " Ensure modems/cables connect the block_reader to this computer.")
|
||||||
|
else
|
||||||
|
for i, entry in ipairs(periList) do
|
||||||
|
table.insert(output, string.format(" [%d] name = %s", i, tostring(entry.name)))
|
||||||
|
table.insert(output, string.format(" getType = %s", tostring(entry.getType)))
|
||||||
|
if entry.matchHint then
|
||||||
|
table.insert(output, string.format(" [HINT] %s", tostring(entry.matchHint)))
|
||||||
|
end
|
||||||
|
if entry.getNameCall ~= nil then
|
||||||
|
table.insert(output, string.format(" getName() = %s", tostring(entry.getNameCall)))
|
||||||
|
end
|
||||||
|
if entry.methodsSorted then
|
||||||
|
table.insert(output, string.format(" getMethods() = [ %s ]", table.concat(entry.methodsSorted, ", ")))
|
||||||
|
end
|
||||||
|
if entry.methodsHint then
|
||||||
|
table.insert(output, string.format(" [HINT] %s", tostring(entry.methodsHint)))
|
||||||
|
end
|
||||||
|
table.insert(output, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(output, "[Troubleshooting Tips]")
|
||||||
|
table.insert(output, " 1. Verify the block_reader is adjacent/connected via modem.")
|
||||||
|
table.insert(output, " 2. In CC, run: lua -e \"p=peripheral.getNames(); for _,n in ipairs(p) do print(n, peripheral.getType(n)) end\"")
|
||||||
|
local fullContent = table.concat(output, "\n")
|
||||||
|
local ok, err = writeToFile(fullContent)
|
||||||
|
if not ok then
|
||||||
|
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||||
|
return 3
|
||||||
|
end
|
||||||
|
log("SUCCESS", "Peripheral diagnostics written to " .. OUTPUT_FILE)
|
||||||
|
local size = fs.getSize(OUTPUT_FILE)
|
||||||
|
log("INFO", "File size: " .. tostring(size) .. " bytes")
|
||||||
|
print()
|
||||||
|
print("Press any key to exit...")
|
||||||
|
os.pullEvent("key")
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
log("SUCCESS", "block_reader found: " .. tostring(readerName))
|
||||||
|
table.insert(output, "BlockReader: " .. tostring(readerName))
|
||||||
|
table.insert(output, "")
|
||||||
|
|
||||||
|
if type(reader.isBlockPresent) ~= "function" then
|
||||||
|
log("WARN", "isBlockPresent() not available on this block_reader, trying direct read...")
|
||||||
|
else
|
||||||
|
local ok, present = pcall(function() return reader.isBlockPresent() end)
|
||||||
|
if ok and not present then
|
||||||
|
log("ERROR", "No block in front of block_reader. Place the Advanced Display block adjacent to the block_reader.")
|
||||||
|
table.insert(output, "[Block Present Check]")
|
||||||
|
table.insert(output, " isBlockPresent() = false")
|
||||||
|
local fullContent = table.concat(output, "\n")
|
||||||
|
local ok2, err = writeToFile(fullContent)
|
||||||
|
if not ok2 then
|
||||||
|
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||||
|
return 3
|
||||||
|
end
|
||||||
|
log("SUCCESS", "Result written to " .. OUTPUT_FILE)
|
||||||
|
return 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(reader.getBlockName) == "function" then
|
||||||
|
local ok, name = pcall(function() return reader.getBlockName() end)
|
||||||
|
if ok and name then
|
||||||
|
table.insert(output, "[Block Registry Name]")
|
||||||
|
table.insert(output, " " .. tostring(name))
|
||||||
|
table.insert(output, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(reader.getBlockState) == "function" then
|
||||||
|
local ok, state = pcall(function() return reader.getBlockState() end)
|
||||||
|
if ok and state then
|
||||||
|
table.insert(output, "[Block State]")
|
||||||
|
if type(state) == "table" and next(state) == nil then
|
||||||
|
table.insert(output, " (empty)")
|
||||||
|
else
|
||||||
|
table.insert(output, serializeValue(state, 0))
|
||||||
|
end
|
||||||
|
table.insert(output, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(reader.getBlockData) == "function" then
|
||||||
|
local ok, data = pcall(function() return reader.getBlockData() end)
|
||||||
|
if ok and data then
|
||||||
|
table.insert(output, "[Block NBT Data]")
|
||||||
|
if type(data) == "table" and next(data) == nil then
|
||||||
|
table.insert(output, " (empty)")
|
||||||
|
else
|
||||||
|
table.insert(output, serializeValue(data, 0))
|
||||||
|
end
|
||||||
|
table.insert(output, "")
|
||||||
|
else
|
||||||
|
log("WARN", "getBlockData() failed or returned nothing: " .. tostring(data))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log("WARN", "getBlockData() not found on this block_reader")
|
||||||
|
end
|
||||||
|
|
||||||
|
local fullContent = table.concat(output, "\n")
|
||||||
|
local ok, err = writeToFile(fullContent)
|
||||||
|
if not ok then
|
||||||
|
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||||
|
return 3
|
||||||
|
end
|
||||||
|
|
||||||
|
log("SUCCESS", "NBT data written to " .. OUTPUT_FILE)
|
||||||
|
local size = fs.getSize(OUTPUT_FILE)
|
||||||
|
log("INFO", "File size: " .. tostring(size) .. " bytes")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Press any key to exit...")
|
||||||
|
os.pullEvent("key")
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local exitCode = 0
|
||||||
|
local ok, err = pcall(main)
|
||||||
|
if not ok then
|
||||||
|
log("ERROR", "Program crashed: " .. tostring(err))
|
||||||
|
exitCode = 99
|
||||||
|
end
|
||||||
|
|
||||||
|
if exitCode ~= 0 then
|
||||||
|
print()
|
||||||
|
print("Press any key to exit...")
|
||||||
|
os.pullEvent("key")
|
||||||
|
end
|
||||||
|
|
||||||
|
error("", exitCode)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
function getKeywords(text)
|
||||||
|
if not text then return {} end
|
||||||
|
local s = tostring(text):lower()
|
||||||
|
|
||||||
|
local keywords = {}
|
||||||
|
|
||||||
|
for code in s:gmatch("[%a%d]+%-[%a%d]+") do
|
||||||
|
keywords[code] = true
|
||||||
|
end
|
||||||
|
for code in s:gmatch("%a+%d+") do
|
||||||
|
keywords[code] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
for zh in s:gmatch("[\128-\255]+") do
|
||||||
|
local cleanZh = zh:gsub("站台", ""):gsub("轨道", ""):gsub("号", ""):gsub("线", "")
|
||||||
|
if cleanZh and cleanZh ~= "" then
|
||||||
|
keywords[cleanZh] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local list = {}
|
||||||
|
for k, _ in pairs(keywords) do
|
||||||
|
table.insert(list, k)
|
||||||
|
end
|
||||||
|
return list
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test(a, b)
|
||||||
|
local k1 = getKeywords(a)
|
||||||
|
local k2 = getKeywords(b)
|
||||||
|
local match = false
|
||||||
|
for _, w1 in ipairs(k1) do
|
||||||
|
for _, w2 in ipairs(k2) do
|
||||||
|
if w1 == w2 or w1:find(w2, 1, true) or w2:find(w1, 1, true) then
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print(a .. " vs " .. b .. " -> " .. tostring(match))
|
||||||
|
print(" K1: " .. table.concat(k1, ", "))
|
||||||
|
print(" K2: " .. table.concat(k2, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
test("CE-03 (桃园)", "桃园")
|
||||||
|
test("CE-04", "CE-04 [西门]")
|
||||||
|
test("CRH-01", "CRH-01 南山线")
|
||||||
|
test("南山线", "南山")
|
||||||
|
test("???", "Unknown")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
||||||
|
|
||||||
local function fetch(url)
|
local function fetch(url)
|
||||||
|
|||||||
Reference in New Issue
Block a user