根因:
- cablemanager 每 900ms scheduleAutoReload() 通过 WS state_update 触发 reloadAll({silent:true}),之前 reloadAll 在 detail-view 场景下每次都会无条件把 detailTempMembers 从服务器读回、selectedAvailable/selectedMembers 清空,所以用户刚把某站台 » 加进成员,下一次被动刷新到了就立刻还原(视觉上就是「点过去又跳回来」)
- 保存 saveDetail() PUT 本身是能成功的(server PUT /api/audio-cable-groups/:id 的 stationNames 接口契约没问题),但保存之后 loadGroups() 拉完数据又立刻触发 broadcastUpdate()→WS state_update→scheduleAutoReload→又一次 reloadAll()→把刚保存的 detailTempMembers 用服务器值覆盖掉,用户看起来就是「保存无效」
修复 (cablemanager.html reloadAll):
1. 保存 refresh 前后的 prevGroups/prevStations 快照,只有 name/color/description 或站台列表真的变了(groupListChanged/ stationListChanged)才重绘列表和快选,避免心跳、lastSeen、tokenHolder 这些纯瞬时字段反复重绘
2. 区分 silent (被动自动刷) vs 非 silent (用户手动点刷新):
- 非 silent:保持原语义,完全用服务器值覆盖所有本地编辑(用户主动点刷新就是要回到当前后台状态)
- silent:
* 只在 metaChanged 时同步 name/description/color(并且不抢焦点的 input,避免正在打字被重置)
* 只在 membersChanged(后台 stationNames 真的跟上次不一样)时才同步成员到 detailTempMembers,否则保持用户未保存的成员编辑不变;同时清理 selected 中已不存在的名字
* renderDualLists 仅在 membersChanged || stationListChanged || metaChanged 才调用,其余时间完全不重绘详情
3. 服务器 PUT 端再验证过 stationNames 接口契约没问题:前端 saveDetail 发 {name,color,description,stationNames} 对得上 server PUT handler,保存完 /api/audio-cable-groups 读回 stationNames 正确
APP_VERSION_TEXT bump 到 v1.0.111
1673 lines
55 KiB
HTML
1673 lines
55 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 = Array.isArray(data.audioCableGroups) ? data.audioCableGroups : (Array.isArray(data.groups) ? data.groups : []);
|
|
} 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(options) {
|
|
const silent = !!(options && options.silent);
|
|
try {
|
|
const prevGroups = audioCableGroups.slice();
|
|
const prevStations = stations.slice();
|
|
await Promise.all([loadGroups(), loadStations()]);
|
|
const groupListChanged = JSON.stringify(prevGroups.map(g => ({ id: g.id, name: g.name, color: g.color, description: g.description })))
|
|
!== JSON.stringify(audioCableGroups.map(g => ({ id: g.id, name: g.name, color: g.color, description: g.description })));
|
|
const stationListChanged = prevStations.length !== stations.length
|
|
|| prevStations.some((s, i) => stations[i] && (s.name !== stations[i].name || s.code !== stations[i].code));
|
|
if (groupListChanged || stationListChanged) {
|
|
renderGroupList();
|
|
renderQuickSelects();
|
|
}
|
|
if (currentGroupId) {
|
|
const g = audioCableGroups.find(g => g.id === currentGroupId);
|
|
if (!g) {
|
|
currentGroupId = null;
|
|
backToList();
|
|
} else {
|
|
const pg = prevGroups.find(x => x.id === currentGroupId) || null;
|
|
const membersChanged = !pg || JSON.stringify(pg.stationNames || []) !== JSON.stringify(g.stationNames || []);
|
|
const metaChanged = !pg || pg.name !== g.name || pg.color !== g.color || pg.description !== g.description;
|
|
|
|
if (silent) {
|
|
// 被动自动刷新:同步名字/描述/颜色的标题区,只在后台成员真的变了才同步成员(否则保留用户未保存的编辑)
|
|
const detailNameEl = document.getElementById('detail-name');
|
|
const detailDescEl = document.getElementById('detail-description');
|
|
const detailTitleEl = document.getElementById('detail-title');
|
|
if (metaChanged) {
|
|
if (detailTitleEl && detailNameEl && !detailNameEl.matches(':focus')) {
|
|
detailNameEl.value = g.name || '';
|
|
detailTitleEl.textContent = g.name || '';
|
|
}
|
|
if (detailDescEl && !detailDescEl.matches(':focus')) {
|
|
detailDescEl.value = g.description || '';
|
|
}
|
|
if (detailSelectedColor !== (g.color || PRESET_COLORS[0])) {
|
|
detailSelectedColor = g.color || PRESET_COLORS[0];
|
|
document.getElementById('detail-color').style.background = detailSelectedColor;
|
|
_rebuildDetailColorPicker();
|
|
}
|
|
}
|
|
if (membersChanged) {
|
|
// 后台分组成员已经变了(例如被别的端删除/改了分组)—— 同步掉成员,避免用户编辑的是已经不存在的状态
|
|
detailTempMembers = [...(g.stationNames || [])];
|
|
// 清理选择集中已经不存在的名字,保留仍然存在的
|
|
for (const n of Array.from(selectedAvailable)) {
|
|
if (!stations.some(s => s.name === n)) selectedAvailable.delete(n);
|
|
}
|
|
for (const n of Array.from(selectedMembers)) {
|
|
if (!detailTempMembers.includes(n)) selectedMembers.delete(n);
|
|
}
|
|
}
|
|
if (membersChanged || stationListChanged || metaChanged) renderDualLists();
|
|
} else {
|
|
// 用户主动点刷新:全同步(包括成员)
|
|
detailTempMembers = [...(g.stationNames || [])];
|
|
selectedAvailable.clear();
|
|
selectedMembers.clear();
|
|
renderDualLists();
|
|
}
|
|
}
|
|
}
|
|
if (!silent) showToast('成功', '数据已刷新', 'success');
|
|
} catch (e) {
|
|
if (!silent) showToast('错误', e?.message || String(e), 'error');
|
|
}
|
|
}
|
|
|
|
function sanitizeDisplay(s) {
|
|
let v = String(s == null ? '' : s)
|
|
.replace(/[?¿?]+/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return v === '' ? '-' : v;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
let v = sanitizeDisplay(s);
|
|
return v
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
let v = sanitizeDisplay(s);
|
|
return v
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
let ws;
|
|
let _autoReloadTimer = 0;
|
|
let _autoReloadPending = false;
|
|
function scheduleAutoReload() {
|
|
_autoReloadPending = true;
|
|
const now = Date.now();
|
|
const wait = Math.max(0, 900 - (now - _autoReloadTimer));
|
|
const fire = () => {
|
|
if (!_autoReloadPending) return;
|
|
_autoReloadPending = false;
|
|
_autoReloadTimer = Date.now();
|
|
reloadAll({ silent: true }).catch(() => {});
|
|
};
|
|
if (wait === 0) {
|
|
Promise.resolve().then(fire);
|
|
} else {
|
|
clearTimeout(scheduleAutoReload._t);
|
|
scheduleAutoReload._t = setTimeout(fire, wait);
|
|
}
|
|
}
|
|
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') {
|
|
scheduleAutoReload();
|
|
}
|
|
} 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>
|