Files
CC-StationBroadcast/CloudServer/public/cablemanager.html
T
Henry_Du 4c7d94f2c9 feat(A1+B1): 引入站台多主机识别、音频线缆分组互斥令牌、本地Modem仲裁、线缆分组管理WebUI
服务端 A1 改动(CloudServer/server.js):
- stations 改为 nodes[] 多连接存储,同站台名的多台 CC 主机归入同一组(纯 stationName 精确分组)
- 新增 getStationMaster():hasStationPeripheral + lastSeen 加权选 Master,音频只下发给 Master
- 新增 audioCableGroups 数据结构(持久化到 server-data.json)
- 新增 acquireCableToken/releaseCableToken:同线缆组全局互斥,高优先级发送 audio_preempt 抢占
- 新增 7 路由 REST API:/api/audio-cable-groups CRUD + 批量/单个站台成员管理 + PUT /stations/:name/cable-group 直接改组
- sendAudioControl 给同站组所有节点全量下发(stop/状态同步)
- init_state / state_update 携带 audioCableGroups 给前端
- WS close 自动 removeStationWs 清理节点 + 释放令牌

客户端 B1 改动(station_broadcast.lua v1.0.99):
- Peripherals 新增 wiredModems 扫描(排除无线),自动 rednet.open
- 新增 LocalBus 命名空间:
  - station_hello 每 10s 广播 + 30s 超时清理邻居
  - 同站台名自动组 stationGroup,按 hasStationPeripheral + nodeId 升序选 Master
  - 新增 Utils.isLocalStationMaster(),所有列车上报(arrival/approaching/alert/departure/schedule_sync)仅 Master 执行,彻底解决重复广播
  - acquireLocalToken:audio_probe -> 80ms 收响应 -> 必要时 audio_preempt 抢占 -> token_grant
  - 令牌持有期间每 2s 广播 audio_playing 心跳,30s 超时自动判死回收
- Audio.playerLoop 在 playItem 前获取本地令牌,失败则跳过该音频
- Audio.stop() 自动释放本地令牌
- WS 新增下行处理:audio_preempt / cable_token_denied / station_group_state
- station_identity.cfg 支持 audio_cable_group= 字段,saveStationIdentity 写回
- Network.register/heartbeat 携带 cableGroup 供服务端回填分组

Web 管理端(CloudServer/public/cablemanager.html,单文件无外部依赖):
- 极简硬边风格,对齐 listmanager.html 配色和字体
- 分组网格卡片:色标/名称/描述/成员数/当前令牌持有者 + 编辑删除
- 新增/编辑 Modal:名称+描述+8色预设色板+自定义#RRGGBB
- 分组详情双栏 Dual List:左栏全部站台(搜索+在线状态+在其他组标签)/ 右栏成员,中间批量添加移除按钮
- 顶部快捷操作:站台下拉+组下拉+确认,直接 PUT /stations/:name/cable-group
- WebSocket 监听 state_update 实时刷新,Toast 提示反馈
2026-08-17 15:32:20 +08:00

1594 lines
52 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音频线缆分组管理器</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #121214;
--bg-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--danger: #ef4444;
--success: #10b981;
--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; }
body {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 1280px;
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.quick-assign {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
padding: 12px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
}
.quick-assign-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
text-decoration: none;
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger {
border-color: rgba(239, 68, 68, 0.3);
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { color: var(--text-primary); background: var(--bg-hover); }
.btn-icon.danger:hover { color: var(--danger); background: rgba(239, 68, 68, 0.1); }
.group-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 12px;
max-height: 70vh;
overflow-y: auto;
padding-right: 4px;
}
.group-card {
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 16px;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 12px;
cursor: pointer;
}
.group-card:hover {
border-color: #52525b;
transform: translateY(-1px);
}
.group-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.group-card-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.color-swatch {
width: 20px;
height: 20px;
border-radius: 0;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.1);
}
.group-name {
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.group-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.group-description {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
min-height: 20px;
}
.group-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--bg-border);
flex-wrap: wrap;
gap: 8px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: var(--text-muted);
}
.meta-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--bg-hover);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.token-holder {
color: #3b82f6;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
text-align: center;
gap: 16px;
}
.empty-state svg {
opacity: 0.4;
}
.empty-state-title {
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
}
.empty-state-desc {
font-size: 0.85rem;
max-width: 360px;
line-height: 1.5;
}
.detail-view {
display: none;
flex-direction: column;
gap: 18px;
}
.detail-view.active { display: flex; }
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.detail-title {
display: flex;
align-items: center;
gap: 12px;
}
.detail-title h3 {
margin: 0;
font-weight: 600;
font-size: 1.2rem;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
@media (max-width: 900px) {
.detail-grid { grid-template-columns: 1fr; }
}
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 0.875rem; font-weight: 500; }
input[type="text"], textarea, select {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-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);
}
input[type="text"]:focus, textarea:focus, select:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.color-option {
width: 28px;
height: 28px;
border-radius: 0;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s;
position: relative;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border: 2px solid #fff;
box-shadow: 0 0 0 2px #000, 0 0 0 4px rgba(255,255,255,0.2);
}
.custom-color-input {
display: flex;
gap: 6px;
align-items: center;
}
.custom-color-preview {
width: 28px;
height: 28px;
border-radius: 0;
border: 1px solid var(--bg-border);
flex-shrink: 0;
}
.dual-list {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 14px;
align-items: stretch;
}
@media (max-width: 700px) {
.dual-list {
grid-template-columns: 1fr;
}
.dual-list-actions {
flex-direction: row !important;
justify-content: center;
}
}
.list-box {
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-box-header {
padding: 10px 14px;
border-bottom: 1px solid var(--bg-border);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
}
.list-box-count {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--bg-hover);
padding: 2px 8px;
border-radius: 4px;
}
.list-box-search {
padding: 10px;
border-bottom: 1px solid var(--bg-border);
}
.list-box-search input {
width: 100%;
height: 36px;
padding: 8px 12px;
font-size: 0.85rem;
}
.station-list {
flex: 1;
overflow-y: auto;
max-height: 380px;
padding: 6px;
}
.station-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
gap: 8px;
margin-bottom: 2px;
}
.station-item:hover {
background: var(--bg-hover);
}
.station-item.selected {
background: rgba(59, 130, 246, 0.15);
outline: 1px solid rgba(59, 130, 246, 0.3);
}
.station-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.station-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.station-code {
font-size: 0.72rem;
font-family: var(--font-mono);
color: var(--text-muted);
}
.station-item-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.station-online-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-online {
background: var(--success);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
}
.dot-offline {
background: var(--danger);
}
.group-tag {
font-size: 0.65rem;
padding: 1px 6px;
background: var(--bg-hover);
border-radius: 3px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.group-tag.in-other {
opacity: 0.5;
}
.dual-list-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 10px 0;
}
.dual-btn {
width: 44px;
height: 36px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
font-weight: 600;
font-size: 0.8rem;
}
.dual-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: #52525b;
}
.dual-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(6px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
width: 92%;
max-width: 560px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7);
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.05rem;
}
.modal-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
max-height: 70vh;
overflow-y: auto;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.toast-container {
position: fixed;
top: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-left: 3px solid var(--text-muted);
padding: 12px 18px;
border-radius: 0 6px 6px 0;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 280px;
max-width: 400px;
animation: slideInDown 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.info { border-left-color: #3b82f6; }
@keyframes slideInDown {
from { transform: translateY(-30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.toast-body { flex: 1; min-width: 0; }
.toast-title {
font-weight: 600;
font-size: 0.88rem;
margin-bottom: 2px;
}
.toast-message {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
word-break: break-word;
}
.toast-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s;
}
.toast-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--bg-border);
border-top-color: var(--text-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-input); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: #52525b; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #71717a; }
* { scrollbar-width: thin; scrollbar-color: #52525b var(--bg-input); }
.detail-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding-top: 14px;
border-top: 1px solid var(--bg-border);
}
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>音频线缆分组管理器</h2>
<div class="header-actions">
<a class="btn" href="/">控制台</a>
</div>
</div>
<div id="list-view">
<div class="panel">
<div class="panel-subtitle">
<span>管理音频线缆分组,将站台按物理线缆连接分组,支持令牌传递控制</span>
<div class="toolbar-right">
<div class="quick-assign">
<span class="quick-assign-label">快捷设置站台分组:</span>
<select id="quick-station-select">
<option value="">-- 选择站台 --</option>
</select>
<select id="quick-group-select">
<option value="">-- 选择分组(空=移除) --</option>
</select>
<button class="btn btn-primary" onclick="quickAssignGroup()">确定</button>
</div>
</div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openCreateModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
新建线缆分组
</button>
<div style="display:flex; gap:10px;">
<button class="btn" onclick="reloadAll()">
<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="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
刷新
</button>
</div>
</div>
<div class="group-list" id="group-list">
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-muted)">
<div class="loading-spinner" style="margin-bottom:12px;"></div>
<div>加载中...</div>
</div>
</div>
</div>
</div>
<div class="detail-view" id="detail-view">
<div class="panel">
<div class="detail-header">
<div class="detail-title">
<button class="btn-icon" onclick="backToList()" title="返回列表">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="color-swatch" id="detail-color"></div>
<h3 id="detail-title">分组详情</h3>
</div>
<div style="display:flex; gap:8px;">
<button class="btn" onclick="reloadAll()">刷新</button>
<button class="btn btn-primary" onclick="saveDetail()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
保存分组
</button>
</div>
</div>
<div class="detail-grid">
<div style="display:flex; flex-direction:column; gap:14px;">
<div class="form-group">
<label>分组名称 <span style="color:var(--danger)">*</span></label>
<input type="text" id="detail-name" placeholder="输入分组名称" />
</div>
<div class="form-group">
<label>分组描述</label>
<textarea id="detail-description" placeholder="(可选)描述该线缆分组的用途、覆盖范围等"></textarea>
</div>
<div class="form-group">
<label>分组颜色</label>
<div class="color-picker" id="detail-color-picker"></div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
<div class="form-group">
<label>成员站台(左:可添加 / 右:当前组)</label>
<div class="dual-list">
<div class="list-box">
<div class="list-box-header">
<span>全部站台</span>
<span class="list-box-count" id="available-count">0</span>
</div>
<div class="list-box-search">
<input type="text" id="search-available" placeholder="搜索站台..." oninput="filterAvailable()" />
</div>
<div class="station-list" id="available-list"></div>
</div>
<div class="dual-list-actions">
<button class="dual-btn" onclick="addSelected()" title="添加选中">»</button>
<button class="dual-btn" onclick="addAll()" title="全部添加">»»</button>
<button class="dual-btn" onclick="removeAll()" title="全部移除">««</button>
<button class="dual-btn" onclick="removeSelected()" title="移除选中">«</button>
</div>
<div class="list-box">
<div class="list-box-header">
<span>当前组成员</span>
<span class="list-box-count" id="member-count">0</span>
</div>
<div class="list-box-search">
<input type="text" id="search-member" placeholder="搜索站台..." oninput="filterMembers()" />
</div>
<div class="station-list" id="member-list"></div>
</div>
</div>
</div>
</div>
</div>
<div class="detail-actions">
<button class="btn btn-danger" onclick="deleteCurrentGroup()">
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
删除此分组
</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="group-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">新建线缆分组</span>
<button class="btn-icon" onclick="closeModal()" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>分组名称 <span style="color:var(--danger)">*</span></label>
<input type="text" id="modal-name" placeholder="例如:1号车厢线缆组" />
</div>
<div class="form-group">
<label>分组描述</label>
<textarea id="modal-description" placeholder="(可选)描述该线缆分组的用途"></textarea>
</div>
<div class="form-group">
<label>分组颜色</label>
<div class="color-picker" id="modal-color-picker"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="confirmModal()">确定</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
const ICONS = {
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
station: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3H3v-3z"/><path d="M5 17V9a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v8"/><path d="M9 13h.01"/><path d="M15 13h.01"/></svg>`
};
const PRESET_COLORS = [
'#ef4444', '#f59e0b', '#eab308', '#10b981',
'#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'
];
let audioCableGroups = [];
let stations = [];
let currentGroupId = null;
let modalMode = 'create';
let modalSelectedColor = PRESET_COLORS[0];
let detailSelectedColor = PRESET_COLORS[0];
let detailTempMembers = [];
let selectedAvailable = new Set();
let selectedMembers = new Set();
function showToast(title, message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="toast-body">
<div class="toast-title">${title}</div>
<div class="toast-message">${message}</div>
</div>
<button class="toast-close" onclick="this.closest('.toast').remove()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-20px)';
toast.style.transition = 'all 0.25s';
setTimeout(() => toast.remove(), 250);
}, 3500);
}
function renderEmptyState() {
return `
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="8" y="20" width="12" height="10" rx="0"/>
<rect x="44" y="20" width="12" height="10" rx="0"/>
<rect x="26" y="38" width="12" height="10" rx="0"/>
<path d="M20 25 H32 H44"/>
<path d="M26 40 V32 H38 V40"/>
<path d="M14 20 V14 H50 V20"/>
<path d="M8 14 H56"/>
</svg>
<div class="empty-state-title">暂无线缆分组</div>
<div class="empty-state-desc">点击上方「新建线缆分组」按钮,将站台按物理线缆连接关系组织成分组,便于令牌传递与广播控制。</div>
</div>
`;
}
function getGroupNameById(id) {
const g = audioCableGroups.find(g => g.id === id);
return g ? g.name : '';
}
function getStationGroupId(stationName) {
for (const g of audioCableGroups) {
if (Array.isArray(g.stationNames) && g.stationNames.includes(stationName)) {
return g.id;
}
}
return null;
}
function renderGroupList() {
const container = document.getElementById('group-list');
container.innerHTML = '';
if (audioCableGroups.length === 0) {
container.innerHTML = renderEmptyState();
return;
}
audioCableGroups.forEach(group => {
const card = document.createElement('div');
card.className = 'group-card';
card.onclick = (e) => {
if (e.target.closest('button')) return;
openDetailView(group.id);
};
const memberCount = Array.isArray(group.stationNames) ? group.stationNames.length : (group.memberCount || 0);
const tokenHtml = group.tokenHolder
? `<span class="token-holder" title="当前令牌持有者">🔑 ${group.tokenHolder}</span>`
: `<span style="font-size:0.75rem; color:var(--text-muted); opacity:0.6;">无令牌持有者</span>`;
card.innerHTML = `
<div class="group-card-header">
<div class="group-card-title">
<div class="color-swatch" style="background:${group.color || '#3b82f6'}"></div>
<div class="group-name">${escapeHtml(group.name)}</div>
</div>
<div class="group-card-actions">
<button class="btn-icon" onclick="event.stopPropagation(); openEditModal('${group.id}')" title="编辑基本信息">${ICONS.edit}</button>
<button class="btn-icon danger" onclick="event.stopPropagation(); deleteGroup('${group.id}')" title="删除分组">${ICONS.trash}</button>
</div>
</div>
<div class="group-description">${group.description ? escapeHtml(group.description) : '<span style="opacity:0.5;">(无描述)</span>'}</div>
<div class="group-meta">
<div class="meta-item">
${ICONS.station}
<span>成员站台</span>
<span class="meta-badge">${memberCount}</span>
</div>
${tokenHtml}
</div>
`;
container.appendChild(card);
});
}
function renderColorPicker(containerId, selectedColor, onSelect) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
PRESET_COLORS.forEach(color => {
const btn = document.createElement('div');
btn.className = 'color-option' + (selectedColor === color ? ' selected' : '');
btn.style.background = color;
btn.onclick = () => onSelect(color);
container.appendChild(btn);
});
const customDiv = document.createElement('div');
customDiv.className = 'custom-color-input';
const isCustom = !PRESET_COLORS.includes(selectedColor) && /^#[0-9A-Fa-f]{6}$/.test(selectedColor);
customDiv.innerHTML = `
<div class="custom-color-preview" id="${containerId}-custom-preview" style="${isCustom ? 'background:' + selectedColor : ''}"></div>
<input type="text" id="${containerId}-custom-input" maxlength="7" placeholder="#RRGGBB" value="${isCustom ? selectedColor : ''}"
style="height:36px; padding:6px 10px; width:100px; font-size:0.8rem;" />
`;
container.appendChild(customDiv);
setTimeout(() => {
const input = document.getElementById(`${containerId}-custom-input`);
const preview = document.getElementById(`${containerId}-custom-preview`);
if (input) {
input.addEventListener('input', (e) => {
let val = e.target.value.trim();
if (!val.startsWith('#') && val.length > 0) val = '#' + val;
val = val.toUpperCase();
e.target.value = val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
preview.style.background = val;
onSelect(val);
}
});
}
}, 0);
}
function renderDualLists() {
const searchAvail = (document.getElementById('search-available')?.value || '').toLowerCase();
const searchMem = (document.getElementById('search-member')?.value || '').toLowerCase();
const availContainer = document.getElementById('available-list');
const memContainer = document.getElementById('member-list');
availContainer.innerHTML = '';
memContainer.innerHTML = '';
const currentMemberSet = new Set(detailTempMembers);
const availableStations = [];
const memberStations = [];
stations.forEach(st => {
if (currentMemberSet.has(st.name)) {
if (!searchMem || st.name.toLowerCase().includes(searchMem) || (st.code || '').toLowerCase().includes(searchMem)) {
memberStations.push(st);
}
} else {
if (!searchAvail || st.name.toLowerCase().includes(searchAvail) || (st.code || '').toLowerCase().includes(searchAvail)) {
availableStations.push(st);
}
}
});
availableStations.forEach(st => {
const item = document.createElement('div');
const otherGroupId = getStationGroupId(st.name);
const otherGroupName = otherGroupId ? getGroupNameById(otherGroupId) : '';
const isOnline = (Date.now() - (st.lastSeen || 0)) < 60000;
item.className = 'station-item' + (selectedAvailable.has(st.name) ? ' selected' : '');
item.onclick = (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.shiftKey || e.ctrlKey || e.metaKey) {
selectedAvailable.has(st.name) ? selectedAvailable.delete(st.name) : selectedAvailable.add(st.name);
} else {
selectedAvailable.clear();
selectedAvailable.add(st.name);
}
renderDualLists();
};
item.innerHTML = `
<div class="station-item-info">
<div class="station-name">${escapeHtml(st.name)}</div>
<div class="station-code">${escapeHtml(st.code || '-')}</div>
</div>
<div class="station-item-right">
${otherGroupName ? `<span class="group-tag in-other" title="已属于其他分组">在组${escapeHtml(otherGroupName)}中</span>` : ''}
<div class="station-online-dot ${isOnline ? 'dot-online' : 'dot-offline'}"></div>
</div>
`;
availContainer.appendChild(item);
});
memberStations.forEach(st => {
const item = document.createElement('div');
const isOnline = (Date.now() - (st.lastSeen || 0)) < 60000;
item.className = 'station-item' + (selectedMembers.has(st.name) ? ' selected' : '');
item.onclick = (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.shiftKey || e.ctrlKey || e.metaKey) {
selectedMembers.has(st.name) ? selectedMembers.delete(st.name) : selectedMembers.add(st.name);
} else {
selectedMembers.clear();
selectedMembers.add(st.name);
}
renderDualLists();
};
item.innerHTML = `
<div class="station-item-info">
<div class="station-name">${escapeHtml(st.name)}</div>
<div class="station-code">${escapeHtml(st.code || '-')}</div>
</div>
<div class="station-item-right">
<div class="station-online-dot ${isOnline ? 'dot-online' : 'dot-offline'}"></div>
</div>
`;
memContainer.appendChild(item);
});
if (availableStations.length === 0) {
availContainer.innerHTML = '<div style="text-align:center; padding:30px; color:var(--text-muted); font-size:0.8rem;">没有可用站台</div>';
}
if (memberStations.length === 0) {
memContainer.innerHTML = '<div style="text-align:center; padding:30px; color:var(--text-muted); font-size:0.8rem;">暂无成员</div>';
}
document.getElementById('available-count').textContent = availableStations.length;
document.getElementById('member-count').textContent = detailTempMembers.length;
}
function filterAvailable() {
selectedAvailable.clear();
renderDualLists();
}
function filterMembers() {
selectedMembers.clear();
renderDualLists();
}
function addSelected() {
selectedAvailable.forEach(name => {
if (!detailTempMembers.includes(name)) detailTempMembers.push(name);
});
selectedAvailable.clear();
renderDualLists();
}
function removeSelected() {
selectedMembers.forEach(name => {
const idx = detailTempMembers.indexOf(name);
if (idx >= 0) detailTempMembers.splice(idx, 1);
});
selectedMembers.clear();
renderDualLists();
}
function addAll() {
stations.forEach(st => {
if (!detailTempMembers.includes(st.name)) detailTempMembers.push(st.name);
});
selectedAvailable.clear();
renderDualLists();
}
function removeAll() {
if (!confirm('确定要移除当前分组的全部成员吗?')) return;
detailTempMembers = [];
selectedMembers.clear();
renderDualLists();
}
function renderQuickSelects() {
const stationSelect = document.getElementById('quick-station-select');
const groupSelect = document.getElementById('quick-group-select');
stationSelect.innerHTML = '<option value="">-- 选择站台 --</option>';
stations.forEach(st => {
stationSelect.innerHTML += `<option value="${escapeAttr(st.name)}">${escapeHtml(st.name)} (${escapeHtml(st.code || '-')})</option>`;
});
groupSelect.innerHTML = '<option value="">-- 选择分组(空=移除) --</option>';
audioCableGroups.forEach(g => {
groupSelect.innerHTML += `<option value="${escapeAttr(g.id)}">${escapeHtml(g.name)}</option>`;
});
}
async function quickAssignGroup() {
const stationName = document.getElementById('quick-station-select').value;
const cableGroupId = document.getElementById('quick-group-select').value;
if (!stationName) {
showToast('提示', '请先选择要设置的站台', 'info');
return;
}
try {
const res = await fetch(`/api/stations/${encodeURIComponent(stationName)}/cable-group`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cableGroupId })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || '设置失败');
showToast('成功', cableGroupId ? `已将 ${stationName} 加入分组` : `已将 ${stationName} 移除分组`, 'success');
await loadGroups();
renderQuickSelects();
renderGroupList();
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
}
}
function openCreateModal() {
modalMode = 'create';
document.getElementById('modal-title').textContent = '新建线缆分组';
document.getElementById('modal-name').value = '';
document.getElementById('modal-description').value = '';
modalSelectedColor = PRESET_COLORS[0];
renderColorPicker('modal-color-picker', modalSelectedColor, (c) => {
modalSelectedColor = c;
renderColorPicker('modal-color-picker', modalSelectedColor, arguments.callee.caller.arguments[2] || ((c2) => { modalSelectedColor = c2; }));
});
_rebuildModalColorPicker();
document.getElementById('group-modal').classList.add('active');
}
function _rebuildModalColorPicker() {
const container = document.getElementById('modal-color-picker');
container.innerHTML = '';
PRESET_COLORS.forEach(color => {
const btn = document.createElement('div');
btn.className = 'color-option' + (modalSelectedColor === color ? ' selected' : '');
btn.style.background = color;
btn.onclick = () => { modalSelectedColor = color; _rebuildModalColorPicker(); };
container.appendChild(btn);
});
const customDiv = document.createElement('div');
customDiv.className = 'custom-color-input';
const isCustom = !PRESET_COLORS.includes(modalSelectedColor) && /^#[0-9A-Fa-f]{6}$/.test(modalSelectedColor);
customDiv.innerHTML = `
<div class="custom-color-preview" id="modal-custom-preview" style="${isCustom ? 'background:' + modalSelectedColor : ''}"></div>
<input type="text" id="modal-custom-input" maxlength="7" placeholder="#RRGGBB" value="${isCustom ? modalSelectedColor : ''}"
style="height:36px; padding:6px 10px; width:100px; font-size:0.8rem;" />
`;
container.appendChild(customDiv);
setTimeout(() => {
const input = document.getElementById('modal-custom-input');
const preview = document.getElementById('modal-custom-preview');
if (input) {
input.addEventListener('input', (e) => {
let val = e.target.value.trim();
if (!val.startsWith('#') && val.length > 0) val = '#' + val;
val = val.toUpperCase();
e.target.value = val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
preview.style.background = val;
modalSelectedColor = val;
_rebuildModalColorPicker();
document.getElementById('modal-custom-input').value = val;
document.getElementById('modal-custom-preview').style.background = val;
const inp = document.getElementById('modal-custom-input');
inp.focus();
inp.setSelectionRange(val.length, val.length);
}
});
}
}, 0);
}
function openEditModal(groupId) {
const group = audioCableGroups.find(g => g.id === groupId);
if (!group) return;
modalMode = 'edit';
document.getElementById('modal-title').textContent = '编辑线缆分组';
document.getElementById('modal-name').value = group.name || '';
document.getElementById('modal-description').value = group.description || '';
modalSelectedColor = group.color || PRESET_COLORS[0];
_editModalGroupId = groupId;
_rebuildModalColorPicker();
document.getElementById('group-modal').classList.add('active');
}
let _editModalGroupId = null;
function closeModal() {
document.getElementById('group-modal').classList.remove('active');
_editModalGroupId = null;
}
async function confirmModal() {
const name = document.getElementById('modal-name').value.trim();
const description = document.getElementById('modal-description').value.trim();
if (!name) {
showToast('错误', '分组名称不能为空', 'error');
return;
}
try {
if (modalMode === 'create') {
const res = await fetch('/api/audio-cable-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: modalSelectedColor, description, stationNames: [] })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || '创建失败');
showToast('成功', `分组「${name}」已创建`, 'success');
} else {
const res = await fetch(`/api/audio-cable-groups/${encodeURIComponent(_editModalGroupId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: modalSelectedColor, description })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || '更新失败');
showToast('成功', `分组「${name}」已更新`, 'success');
}
closeModal();
await loadGroups();
renderGroupList();
renderQuickSelects();
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
}
}
function openDetailView(groupId) {
const group = audioCableGroups.find(g => g.id === groupId);
if (!group) return;
currentGroupId = groupId;
detailSelectedColor = group.color || PRESET_COLORS[0];
detailTempMembers = [...(group.stationNames || [])];
selectedAvailable.clear();
selectedMembers.clear();
document.getElementById('list-view').style.display = 'none';
document.getElementById('detail-view').classList.add('active');
document.getElementById('detail-title').textContent = group.name;
document.getElementById('detail-color').style.background = detailSelectedColor;
document.getElementById('detail-name').value = group.name || '';
document.getElementById('detail-description').value = group.description || '';
_rebuildDetailColorPicker();
renderDualLists();
}
function _rebuildDetailColorPicker() {
const container = document.getElementById('detail-color-picker');
container.innerHTML = '';
PRESET_COLORS.forEach(color => {
const btn = document.createElement('div');
btn.className = 'color-option' + (detailSelectedColor === color ? ' selected' : '');
btn.style.background = color;
btn.onclick = () => {
detailSelectedColor = color;
document.getElementById('detail-color').style.background = color;
_rebuildDetailColorPicker();
};
container.appendChild(btn);
});
const customDiv = document.createElement('div');
customDiv.className = 'custom-color-input';
const isCustom = !PRESET_COLORS.includes(detailSelectedColor) && /^#[0-9A-Fa-f]{6}$/.test(detailSelectedColor);
customDiv.innerHTML = `
<div class="custom-color-preview" id="detail-custom-preview" style="${isCustom ? 'background:' + detailSelectedColor : ''}"></div>
<input type="text" id="detail-custom-input" maxlength="7" placeholder="#RRGGBB" value="${isCustom ? detailSelectedColor : ''}"
style="height:36px; padding:6px 10px; width:100px; font-size:0.8rem;" />
`;
container.appendChild(customDiv);
setTimeout(() => {
const input = document.getElementById('detail-custom-input');
const preview = document.getElementById('detail-custom-preview');
if (input) {
input.addEventListener('input', (e) => {
let val = e.target.value.trim();
if (!val.startsWith('#') && val.length > 0) val = '#' + val;
val = val.toUpperCase();
e.target.value = val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
preview.style.background = val;
detailSelectedColor = val;
document.getElementById('detail-color').style.background = val;
_rebuildDetailColorPicker();
const inp = document.getElementById('detail-custom-input');
if (inp) { inp.value = val; inp.focus(); inp.setSelectionRange(val.length, val.length); }
document.getElementById('detail-custom-preview').style.background = val;
}
});
}
}, 0);
}
function backToList() {
currentGroupId = null;
document.getElementById('detail-view').classList.remove('active');
document.getElementById('list-view').style.display = '';
}
async function saveDetail() {
if (!currentGroupId) return;
const name = document.getElementById('detail-name').value.trim();
const description = document.getElementById('detail-description').value.trim();
if (!name) {
showToast('错误', '分组名称不能为空', 'error');
return;
}
try {
const res = await fetch(`/api/audio-cable-groups/${encodeURIComponent(currentGroupId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: detailSelectedColor, description, stationNames: detailTempMembers })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || '保存失败');
showToast('成功', `分组「${name}」已保存`, 'success');
document.getElementById('detail-title').textContent = name;
await loadGroups();
renderGroupList();
renderQuickSelects();
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
}
}
async function deleteGroup(groupId) {
const group = audioCableGroups.find(g => g.id === groupId);
if (!group) return;
if (!confirm(`确定要删除分组「${group.name}」吗?该操作不可撤销。`)) return;
try {
const res = await fetch(`/api/audio-cable-groups/${encodeURIComponent(groupId)}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) throw new Error(data.message || '删除失败');
showToast('成功', `分组「${group.name}」已删除`, 'success');
await loadGroups();
renderGroupList();
renderQuickSelects();
if (currentGroupId === groupId) backToList();
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
}
}
async function deleteCurrentGroup() {
if (currentGroupId) await deleteGroup(currentGroupId);
}
async function loadGroups() {
const res = await fetch('/api/audio-cable-groups');
const data = await res.json();
if (data.success) {
audioCableGroups = data.audioCableGroups || [];
} else {
throw new Error(data.message || '加载分组失败');
}
}
async function loadStations() {
const res = await fetch('/api/stations');
const data = await res.json();
if (data.success) {
stations = data.stations || [];
} else {
stations = Array.isArray(data) ? data : (data.stations || []);
}
if (!Array.isArray(stations)) stations = [];
}
async function reloadAll() {
try {
await Promise.all([loadGroups(), loadStations()]);
renderGroupList();
renderQuickSelects();
if (currentGroupId) {
const g = audioCableGroups.find(g => g.id === currentGroupId);
if (g) {
detailTempMembers = [...(g.stationNames || [])];
selectedAvailable.clear();
selectedMembers.clear();
renderDualLists();
}
}
showToast('成功', '数据已刷新', 'success');
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
}
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return String(s == null ? '' : s)
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
let ws;
function connectWebSocket() {
try {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProto}://${window.location.host}`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'console_connect' }));
};
ws.onclose = () => {
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init_state' || data.type === 'state_update') {
reloadAll().catch(() => {});
}
} catch (e) {}
};
ws.onerror = () => { try { ws.close(); } catch(e) {} };
} catch (e) {}
}
async function init() {
try {
await Promise.all([loadGroups(), loadStations()]);
renderGroupList();
renderQuickSelects();
} catch (e) {
showToast('错误', e?.message || String(e), 'error');
document.getElementById('group-list').innerHTML = `
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-muted)">
加载失败:${escapeHtml(e?.message || String(e))}
<div style="margin-top:12px;"><button class="btn" onclick="reloadAll()">重试</button></div>
</div>
`;
}
connectWebSocket();
}
init();
</script>
</body>
</html>