Files
frost-mqtt-client/design/index.html

1738 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frost MQTT Client</title>
<style>
:root {
--bg-primary: #f8f9fb;
--bg-secondary: #ffffff;
--bg-tertiary: #f0f2f5;
--bg-hover: #e8ecf1;
--bg-active: #dce3eb;
--border: #e2e6ea;
--border-light: #eef0f3;
--text-primary: #1a1d23;
--text-secondary: #5a5f6b;
--text-tertiary: #8b909e;
--text-muted: #b0b5c0;
--accent: #0ea5a0;
--accent-hover: #0b8c87;
--accent-light: #e6f7f6;
--accent-subtle: #f0faf9;
--success: #22c55e;
--success-light: #e6f9ef;
--warning: #f59e0b;
--warning-light: #fff8eb;
--danger: #ef4444;
--danger-light: #fef0f0;
--info: #3b82f6;
--info-light: #eff5ff;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.08);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--font-sans: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
--sidebar-width: 300px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
display: flex;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar-header {
padding: 18px 20px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.sidebar-logo {
width: 34px;
height: 34px;
background: var(--accent);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
}
.sidebar-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.sidebar-subtitle {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 500;
}
.server-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.server-item {
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 4px;
transition: all 0.15s ease;
border: 1px solid transparent;
position: relative;
}
.server-item:hover {
background: var(--bg-tertiary);
}
.server-item.active {
background: var(--accent-subtle);
border-color: var(--accent);
}
.server-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.server-item-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-item-url {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.disconnected { background: var(--text-muted); }
.status-dot.connecting { background: var(--warning); animation: pulse 1s infinite; }
.status-dot.connected { background: var(--success); }
.status-dot.error { background: var(--danger); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.server-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.server-item:hover .server-item-actions { opacity: 1; }
.icon-btn {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
transition: all 0.15s;
font-size: 14px;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-btn.danger:hover {
background: var(--danger-light);
color: var(--danger);
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-outline {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border);
}
.btn-outline:hover {
background: var(--bg-tertiary);
border-color: var(--text-muted);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
border-radius: var(--radius-sm);
}
.btn-xs {
padding: 3px 8px;
font-size: 11px;
border-radius: 4px;
}
.btn-success {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.btn-success:hover {
background: #1ca04a;
}
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: var(--warning);
color: #fff;
border-color: var(--warning);
}
.btn-full {
width: 100%;
}
/* ===== Main Content ===== */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg-primary);
}
.main-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0 24px;
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 50px;
}
.tab-btn {
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-tertiary);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.15s;
font-family: var(--font-sans);
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover { color: var(--text-primary); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.tab-icon {
font-size: 15px;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ===== Cards ===== */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.card-subtitle {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
/* ===== Form Elements ===== */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group.full {
grid-column: 1 / -1;
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-input, .form-select, .form-textarea {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-secondary);
font-family: var(--font-sans);
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
.form-input.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.form-textarea {
resize: vertical;
min-height: 120px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b909e' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.toggle {
position: relative;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 22px;
transition: 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider {
background: var(--accent);
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(18px);
}
.toggle-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* ===== Message Log ===== */
.message-log {
background: #1e1e2e;
border-radius: var(--radius-md);
padding: 16px;
max-height: 400px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
color: #cdd6f4;
}
.msg-entry {
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
display: flex;
gap: 8px;
}
.msg-time {
color: #6c7086;
flex-shrink: 0;
}
.msg-direction {
flex-shrink: 0;
font-weight: 700;
width: 30px;
}
.msg-direction.pub { color: #89b4fa; }
.msg-direction.sub { color: #a6e3a1; }
.msg-topic {
color: #f9e2af;
flex-shrink: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-payload {
color: #cdd6f4;
flex: 1;
word-break: break-all;
}
.msg-meta {
color: #6c7086;
font-size: 11px;
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-state-text {
font-size: 14px;
font-weight: 500;
}
/* ===== Topic List ===== */
.topic-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.topic-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
transition: all 0.15s;
border: 1px solid transparent;
}
.topic-item:hover {
background: var(--bg-hover);
}
.topic-item.subscribed {
border-color: var(--success);
background: var(--success-light);
}
.topic-item-text {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-item-qos {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--bg-hover);
color: var(--text-secondary);
flex-shrink: 0;
}
.topic-item.subscribed .topic-item-qos {
background: var(--success);
color: #fff;
}
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: var(--success-light); color: var(--success); }
.badge-warning { background: var(--warning-light); color: var(--warning); }
.badge-danger { background: var(--danger-light); color: var(--danger); }
.badge-info { background: var(--info-light); color: var(--info); }
/* ===== Stats ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
}
.stat-label {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-top: 4px;
letter-spacing: -0.02em;
}
.stat-value.accent { color: var(--accent); }
/* ===== Modal ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 24px;
width: 520px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* ===== Toolbar ===== */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flex-spacer { flex: 1; }
/* ===== Responsive ===== */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -100%;
top: 0;
bottom: 0;
transition: left 0.25s;
}
.sidebar.open { left: 0; }
.form-grid, .form-grid-2, .form-grid-3 {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
.main-content { padding: 14px; }
}
/* ===== Toast ===== */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideInRight 0.25s ease;
min-width: 250px;
display: flex;
align-items: center;
gap: 8px;
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast-success { background: var(--success); color: #fff; }
.toast-error { background: var(--danger); color: #fff; }
.toast-info { background: var(--info); color: #fff; }
.toast-warning { background: var(--warning); color: #fff; }
/* ===== Section divider ===== */
.section-divider {
border: none;
border-top: 1px solid var(--border-light);
margin: 16px 0;
}
/* ===== Quick actions row ===== */
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">F</div>
<div>
<div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">多功能 MQTT 客户端</div>
</div>
</div>
<div class="server-list" id="serverList">
<!-- 动态渲染 -->
</div>
<div class="sidebar-footer">
<button class="btn btn-primary btn-full" onclick="openServerModal()">
+ 添加服务器
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main">
<div class="main-header" id="mainHeader">
<div class="tab-btn active" data-tab="dashboard">
<span class="tab-icon">&#9681;</span> 仪表盘
</div>
<div class="tab-btn" data-tab="subscribe">
<span class="tab-icon">&#9776;</span> 主题订阅
</div>
<div class="tab-btn" data-tab="publish">
<span class="tab-icon">&#9654;</span> 消息发布
</div>
<div class="tab-btn" data-tab="messages">
<span class="tab-icon">&#9776;</span> 消息监控
</div>
</div>
<div class="main-content" id="mainContent">
<!-- Dashboard Tab -->
<div class="tab-panel active" id="panel-dashboard">
<div class="empty-state" id="dashboardEmpty">
<div class="empty-state-icon">&#9881;</div>
<div class="empty-state-text">请先在左侧添加并选择一个 MQTT 服务器</div>
</div>
<div id="dashboardContent" style="display:none;"></div>
</div>
<!-- Subscribe Tab -->
<div class="tab-panel" id="panel-subscribe">
<div class="empty-state" id="subscribeEmpty">
<div class="empty-state-icon">&#9776;</div>
<div class="empty-state-text">请先在左侧选择一个 MQTT 服务器</div>
</div>
<div id="subscribeContent" style="display:none;">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">添加订阅主题</div>
<div class="card-subtitle">配置需要订阅的 MQTT 主题</div>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">主题 (Topic)</label>
<input class="form-input mono" id="subscribeTopic" placeholder="例如: sensor/+/temperature" onkeydown="if(event.key==='Enter')addTopic()">
</div>
<div class="form-group">
<label class="form-label">QoS</label>
<select class="form-select" id="subscribeQos">
<option value="0">QoS 0 - 最多一次</option>
<option value="1">QoS 1 - 至少一次</option>
<option value="2" selected>QoS 2 - 恰好一次</option>
</select>
</div>
<div class="form-group" style="justify-content:flex-end;">
<button class="btn btn-primary" onclick="addTopic()">+ 添加订阅</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">已订阅主题列表</div>
<button class="btn btn-sm btn-outline" onclick="subscribeAllTopics()">全部订阅</button>
</div>
<div class="topic-list" id="topicList">
<div class="empty-state" style="padding:20px;">
<div class="empty-state-text" style="font-size:13px;">暂无订阅主题,请在上方添加</div>
</div>
</div>
</div>
</div>
</div>
<!-- Publish Tab -->
<div class="tab-panel" id="panel-publish">
<div class="empty-state" id="publishEmpty">
<div class="empty-state-icon">&#9654;</div>
<div class="empty-state-text">请先在左侧选择一个 MQTT 服务器</div>
</div>
<div id="publishContent" style="display:none;">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">发布消息</div>
<div class="card-subtitle">向指定主题发送 MQTT 消息</div>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">目标主题 (Topic)</label>
<input class="form-input mono" id="publishTopic" placeholder="例如: sensor/device01/temperature">
</div>
<div class="form-group">
<label class="form-label">QoS</label>
<select class="form-select" id="publishQos">
<option value="0">QoS 0 - 最多一次</option>
<option value="1" selected>QoS 1 - 至少一次</option>
<option value="2">QoS 2 - 恰好一次</option>
</select>
</div>
<div class="form-group" style="justify-content:flex-end;">
<div class="toggle-row">
<label class="toggle">
<input type="checkbox" id="publishRetain">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">保留消息 (Retain)</span>
</div>
</div>
</div>
<div class="form-group" style="margin-top:14px;">
<label class="form-label">消息内容 (Payload)</label>
<textarea class="form-textarea" id="publishPayload" placeholder='{"temperature": 25.6, "humidity": 68.3}'></textarea>
</div>
<div style="margin-top:14px;display:flex;gap:8px;">
<button class="btn btn-primary" onclick="publishMessage()">&#9654; 发布消息</button>
<button class="btn btn-outline btn-sm" onclick="formatJson()">格式化 JSON</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">发布历史</div>
<button class="btn btn-sm btn-outline" onclick="clearPublishHistory()">清空</button>
</div>
<div class="message-log" id="publishHistory" style="max-height:250px;">
<div style="color:#6c7086;text-align:center;padding:20px;">暂无发布记录</div>
</div>
</div>
</div>
</div>
<!-- Messages Tab -->
<div class="tab-panel" id="panel-messages">
<div class="empty-state" id="messagesEmpty">
<div class="empty-state-icon">&#9776;</div>
<div class="empty-state-text">请先在左侧选择一个 MQTT 服务器</div>
</div>
<div id="messagesContent" style="display:none;">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">消息监控</div>
<div class="card-subtitle">实时查看接收和发送的 MQTT 消息</div>
</div>
<div class="toolbar">
<input class="form-input" style="width:180px;font-size:12px;" placeholder="过滤主题..." id="msgFilter" oninput="renderMessages()">
<button class="btn btn-sm btn-outline" onclick="clearMessages()">清空</button>
<button class="btn btn-sm btn-outline" id="autoScrollBtn" onclick="toggleAutoScroll()">自动滚动: 开</button>
</div>
</div>
<div class="message-log" id="messageLog" style="max-height:500px;">
<div style="color:#6c7086;text-align:center;padding:20px;">等待消息...</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Server Modal -->
<div class="modal-overlay" id="serverModal" style="display:none;" onclick="if(event.target===this)closeServerModal()">
<div class="modal">
<div class="modal-title" id="serverModalTitle">添加 MQTT 服务器</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">服务器名称</label>
<input class="form-input" id="srvName" placeholder="例如: 本地开发服务器">
</div>
<div class="form-group">
<label class="form-label">主机地址 (Host)</label>
<input class="form-input mono" id="srvHost" placeholder="例如: broker.emqx.io">
</div>
<div class="form-group">
<label class="form-label">端口 (Port)</label>
<input class="form-input mono" id="srvPort" placeholder="1883" type="number">
</div>
<div class="form-group">
<label class="form-label">客户端 ID</label>
<input class="form-input mono" id="srvClientId" placeholder="自动生成">
</div>
<div class="form-group">
<label class="form-label">用户名</label>
<input class="form-input" id="srvUsername" placeholder="可选">
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input class="form-input" id="srvPassword" type="password" placeholder="可选">
</div>
<div class="form-group">
<label class="form-label">协议</label>
<select class="form-select" id="srvProtocol">
<option value="mqtt">mqtt:// (默认)</option>
<option value="mqtts">mqtts:// (TLS)</option>
<option value="ws">ws:// (WebSocket)</option>
<option value="wss">wss:// (WebSocket TLS)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Keep Alive (秒)</label>
<input class="form-input" id="srvKeepAlive" placeholder="60" type="number" value="60">
</div>
<div class="form-group full">
<div class="toggle-row">
<label class="toggle">
<input type="checkbox" id="srvCleanSession">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">Clean Session</span>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeServerModal()">取消</button>
<button class="btn btn-primary" id="srvSaveBtn" onclick="saveServer()">保存</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ==================== 数据模型 ====================
const DEFAULT_SERVERS = [
{
id: 'srv_001',
name: 'EMQX 公共测试',
host: 'broker.emqx.io',
port: 1883,
protocol: 'mqtt',
clientId: 'frost_client_' + Math.random().toString(36).slice(2, 8),
username: '',
password: '',
keepAlive: 60,
cleanSession: true,
status: 'disconnected', // disconnected | connecting | connected | error
topics: [
{ topic: 'sensor/+/temperature', qos: 1, subscribed: false },
{ topic: 'device/status', qos: 2, subscribed: false },
],
messages: [],
publishHistory: []
},
{
id: 'srv_002',
name: '本地 Mosquitto',
host: 'localhost',
port: 1883,
protocol: 'mqtt',
clientId: 'frost_local_' + Math.random().toString(36).slice(2, 8),
username: '',
password: '',
keepAlive: 60,
cleanSession: true,
status: 'disconnected',
topics: [
{ topic: 'home/+/light', qos: 1, subscribed: false },
{ topic: 'home/alarm', qos: 2, subscribed: false },
],
messages: [],
publishHistory: []
}
];
let servers = JSON.parse(localStorage.getItem('frost_mqtt_servers') || 'null') || DEFAULT_SERVERS;
let activeServerId = localStorage.getItem('frost_mqtt_active_server') || (servers.length > 0 ? servers[0].id : null);
let editingServerId = null;
let autoScroll = true;
// ==================== 工具函数 ====================
function saveData() {
localStorage.setItem('frost_mqtt_servers', JSON.stringify(servers));
localStorage.setItem('frost_mqtt_active_server', activeServerId || '');
}
function getActiveServer() {
return servers.find(s => s.id === activeServerId);
}
function getServer(id) {
return servers.find(s => s.id === id);
}
function generateId() {
return 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
}
function now() {
const d = new Date();
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
}
function showToast(msg, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
// ==================== 服务器管理 ====================
function openServerModal(serverId = null) {
editingServerId = serverId;
const modal = document.getElementById('serverModal');
const title = document.getElementById('serverModalTitle');
const saveBtn = document.getElementById('srvSaveBtn');
if (serverId) {
const srv = getServer(serverId);
if (!srv) return;
title.textContent = '编辑 MQTT 服务器';
saveBtn.textContent = '更新';
document.getElementById('srvName').value = srv.name;
document.getElementById('srvHost').value = srv.host;
document.getElementById('srvPort').value = srv.port;
document.getElementById('srvProtocol').value = srv.protocol;
document.getElementById('srvClientId').value = srv.clientId;
document.getElementById('srvUsername').value = srv.username;
document.getElementById('srvPassword').value = srv.password;
document.getElementById('srvKeepAlive').value = srv.keepAlive;
document.getElementById('srvCleanSession').checked = srv.cleanSession;
} else {
title.textContent = '添加 MQTT 服务器';
saveBtn.textContent = '保存';
document.getElementById('srvName').value = '';
document.getElementById('srvHost').value = '';
document.getElementById('srvPort').value = '1883';
document.getElementById('srvProtocol').value = 'mqtt';
document.getElementById('srvClientId').value = 'frost_' + Math.random().toString(36).slice(2, 10);
document.getElementById('srvUsername').value = '';
document.getElementById('srvPassword').value = '';
document.getElementById('srvKeepAlive').value = '60';
document.getElementById('srvCleanSession').checked = true;
}
modal.style.display = 'flex';
}
function closeServerModal() {
document.getElementById('serverModal').style.display = 'none';
editingServerId = null;
}
function saveServer() {
const name = document.getElementById('srvName').value.trim();
const host = document.getElementById('srvHost').value.trim();
const port = parseInt(document.getElementById('srvPort').value) || 1883;
const protocol = document.getElementById('srvProtocol').value;
const clientId = document.getElementById('srvClientId').value.trim();
const username = document.getElementById('srvUsername').value.trim();
const password = document.getElementById('srvPassword').value;
const keepAlive = parseInt(document.getElementById('srvKeepAlive').value) || 60;
const cleanSession = document.getElementById('srvCleanSession').checked;
if (!name || !host) {
showToast('请填写服务器名称和主机地址', 'warning');
return;
}
if (editingServerId) {
const srv = getServer(editingServerId);
if (srv) {
srv.name = name;
srv.host = host;
srv.port = port;
srv.protocol = protocol;
srv.clientId = clientId;
srv.username = username;
srv.password = password;
srv.keepAlive = keepAlive;
srv.cleanSession = cleanSession;
showToast('服务器配置已更新', 'success');
}
} else {
servers.push({
id: generateId(),
name,
host,
port,
protocol,
clientId: clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
username,
password,
keepAlive,
cleanSession,
status: 'disconnected',
topics: [],
messages: [],
publishHistory: []
});
showToast('服务器已添加', 'success');
}
closeServerModal();
saveData();
renderAll();
}
function deleteServer(serverId) {
if (!confirm('确定要删除此服务器配置吗?')) return;
servers = servers.filter(s => s.id !== serverId);
if (activeServerId === serverId) {
activeServerId = servers.length > 0 ? servers[0].id : null;
}
saveData();
renderAll();
showToast('服务器已删除', 'info');
}
function toggleConnection(serverId) {
const srv = getServer(serverId);
if (!srv) return;
if (srv.status === 'connected') {
// 模拟断开
srv.status = 'disconnected';
// 取消所有订阅状态
srv.topics.forEach(t => t.subscribed = false);
showToast(srv.name + ' 已断开连接', 'info');
} else if (srv.status === 'disconnected' || srv.status === 'error') {
// 模拟连接过程
srv.status = 'connecting';
renderAll();
setTimeout(() => {
srv.status = 'connected';
saveData();
renderAll();
showToast(srv.name + ' 连接成功', 'success');
// 自动订阅已配置的主题
srv.topics.forEach(t => { t.subscribed = true; });
saveData();
renderAll();
}, 800 + Math.random() * 600);
return;
} else {
return; // 连接中,不响应
}
saveData();
renderAll();
}
function selectServer(serverId) {
activeServerId = serverId;
saveData();
renderAll();
}
// ==================== 主题管理 ====================
function addTopic() {
const srv = getActiveServer();
if (!srv) return;
const topicInput = document.getElementById('subscribeTopic');
const topic = topicInput.value.trim();
if (!topic) {
showToast('请输入主题名称', 'warning');
return;
}
const qos = parseInt(document.getElementById('subscribeQos').value);
if (srv.topics.find(t => t.topic === topic)) {
showToast('该主题已存在', 'warning');
return;
}
srv.topics.push({ topic, qos, subscribed: srv.status === 'connected' });
topicInput.value = '';
saveData();
renderAll();
showToast('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''), 'success');
}
function removeTopic(topic) {
const srv = getActiveServer();
if (!srv) return;
srv.topics = srv.topics.filter(t => t.topic !== topic);
saveData();
renderAll();
showToast('主题已移除', 'info');
}
function toggleTopicSubscription(topicStr) {
const srv = getActiveServer();
if (!srv) return;
if (srv.status !== 'connected') {
showToast('请先连接服务器', 'warning');
return;
}
const topic = srv.topics.find(t => t.topic === topicStr);
if (!topic) return;
topic.subscribed = !topic.subscribed;
saveData();
renderAll();
showToast(topic.subscribed ? '已订阅: ' + topicStr : '已取消订阅: ' + topicStr, 'info');
}
function subscribeAllTopics() {
const srv = getActiveServer();
if (!srv) return;
if (srv.status !== 'connected') {
showToast('请先连接服务器', 'warning');
return;
}
srv.topics.forEach(t => t.subscribed = true);
saveData();
renderAll();
showToast('已订阅全部主题', 'success');
}
// ==================== 消息发布 ====================
function publishMessage() {
const srv = getActiveServer();
if (!srv) return;
if (srv.status !== 'connected') {
showToast('请先连接服务器', 'warning');
return;
}
const topic = document.getElementById('publishTopic').value.trim();
const payload = document.getElementById('publishPayload').value;
const qos = parseInt(document.getElementById('publishQos').value);
const retain = document.getElementById('publishRetain').checked;
if (!topic) {
showToast('请输入目标主题', 'warning');
return;
}
// 模拟发布
const pubEntry = {
time: now(),
topic,
payload,
qos,
retain,
direction: 'pub'
};
srv.publishHistory.unshift(pubEntry);
if (srv.publishHistory.length > 50) srv.publishHistory.pop();
// 同时添加到消息日志
srv.messages.unshift({
...pubEntry,
id: 'msg_' + Date.now()
});
if (srv.messages.length > 200) srv.messages.pop();
saveData();
renderAll();
showToast('消息已发布到: ' + topic, 'success');
// 模拟收到回显消息
setTimeout(() => {
const srv2 = getServer(srv.id);
if (srv2 && srv2.status === 'connected') {
srv2.messages.unshift({
time: now(),
topic,
payload,
qos,
direction: 'sub',
id: 'msg_' + Date.now()
});
if (srv2.messages.length > 200) srv2.messages.pop();
saveData();
if (activeServerId === srv2.id) renderMessages();
}
}, 300 + Math.random() * 500);
}
function formatJson() {
const textarea = document.getElementById('publishPayload');
try {
const obj = JSON.parse(textarea.value);
textarea.value = JSON.stringify(obj, null, 2);
showToast('JSON 已格式化', 'info');
} catch (e) {
showToast('无效的 JSON 格式', 'warning');
}
}
function clearPublishHistory() {
const srv = getActiveServer();
if (!srv) return;
srv.publishHistory = [];
saveData();
renderAll();
}
// ==================== 消息监控 ====================
function clearMessages() {
const srv = getActiveServer();
if (!srv) return;
srv.messages = [];
saveData();
renderAll();
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollBtn').textContent = '自动滚动: ' + (autoScroll ? '开' : '关');
}
function renderMessages() {
const srv = getActiveServer();
const log = document.getElementById('messageLog');
const filter = (document.getElementById('msgFilter')?.value || '').trim().toLowerCase();
if (!srv || srv.messages.length === 0) {
log.innerHTML = '<div style="color:#6c7086;text-align:center;padding:20px;">等待消息...</div>';
return;
}
let filtered = srv.messages;
if (filter) {
filtered = srv.messages.filter(m => m.topic.toLowerCase().includes(filter));
}
log.innerHTML = filtered.map(m => `
<div class="msg-entry">
<span class="msg-time">${m.time}</span>
<span class="msg-direction ${m.direction}">${m.direction === 'pub' ? 'PUB' : 'SUB'}</span>
<span class="msg-topic" title="${m.topic}">${m.topic}</span>
<span class="msg-payload">${escapeHtml(m.payload.length > 100 ? m.payload.slice(0, 100) + '...' : m.payload)}</span>
<span class="msg-meta">QoS${m.qos}${m.retain ? ' R' : ''}</span>
</div>
`).join('');
if (filtered.length === 0) {
log.innerHTML = '<div style="color:#6c7086;text-align:center;padding:20px;">无匹配消息</div>';
}
if (autoScroll) {
log.scrollTop = 0;
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ==================== 渲染 ====================
function renderAll() {
renderServerList();
renderMainContent();
updateTabVisibility();
}
function renderServerList() {
const list = document.getElementById('serverList');
list.innerHTML = servers.map(srv => {
const activeClass = srv.id === activeServerId ? ' active' : '';
const statusLabel = {
'disconnected': '未连接',
'connecting': '连接中...',
'connected': '已连接',
'error': '连接失败'
}[srv.status] || '未知';
return `
<div class="server-item${activeClass}" onclick="selectServer('${srv.id}')">
<div class="server-item-header">
<span class="server-item-name" title="${srv.name}">${srv.name}</span>
<div class="server-item-actions">
<button class="icon-btn" title="编辑" onclick="event.stopPropagation();openServerModal('${srv.id}')">&#9998;</button>
<button class="icon-btn danger" title="删除" onclick="event.stopPropagation();deleteServer('${srv.id}')">&#10005;</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px;">
<span class="status-dot ${srv.status}"></span>
<span class="server-item-url">${srv.protocol}://${srv.host}:${srv.port}</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:6px;">
<span style="font-size:11px;color:var(--text-muted);">${statusLabel}</span>
<button class="btn btn-xs ${srv.status === 'connected' ? 'btn-danger' : srv.status === 'connecting' ? 'btn-warning' : 'btn-success'}"
onclick="event.stopPropagation();toggleConnection('${srv.id}')"
${srv.status === 'connecting' ? 'disabled' : ''}>
${srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'}
</button>
</div>
</div>
`;
}).join('');
}
function renderMainContent() {
const srv = getActiveServer();
const isConnected = srv && srv.status === 'connected';
// 各面板的空状态/内容切换
['dashboard', 'subscribe', 'publish', 'messages'].forEach(panel => {
const emptyEl = document.getElementById(panel + 'Empty');
const contentEl = document.getElementById(panel + 'Content');
if (!emptyEl || !contentEl) return;
if (srv) {
emptyEl.style.display = 'none';
contentEl.style.display = 'block';
} else {
emptyEl.style.display = 'block';
contentEl.style.display = 'none';
}
});
if (!srv) return;
// 仪表盘
renderDashboard(srv);
// 主题列表
renderTopicList(srv);
// 发布历史
renderPublishHistory(srv);
// 消息监控
renderMessages();
}
function renderDashboard(srv) {
const container = document.getElementById('dashboardContent');
const connectedCount = servers.filter(s => s.status === 'connected').length;
const totalTopics = servers.reduce((sum, s) => sum + s.topics.length, 0);
const subscribedTopics = servers.reduce((sum, s) => sum + s.topics.filter(t => t.subscribed).length, 0);
const totalMessages = servers.reduce((sum, s) => sum + s.messages.length, 0);
container.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">服务器总数</div>
<div class="stat-value">${servers.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">已连接</div>
<div class="stat-value accent">${connectedCount}</div>
</div>
<div class="stat-card">
<div class="stat-label">已订阅主题</div>
<div class="stat-value">${subscribedTopics}<span style="font-size:14px;color:var(--text-tertiary);">/${totalTopics}</span></div>
</div>
<div class="stat-card">
<div class="stat-label">消息总数</div>
<div class="stat-value">${totalMessages}</div>
</div>
</div>
<div class="card" style="margin-top:16px;">
<div class="card-header">
<div>
<div class="card-title">当前服务器: ${srv.name}</div>
<div class="card-subtitle">${srv.protocol}://${srv.host}:${srv.port} &nbsp;|&nbsp; 客户端: ${srv.clientId}</div>
</div>
<span class="badge ${srv.status === 'connected' ? 'badge-success' : srv.status === 'connecting' ? 'badge-warning' : 'badge-danger'}">
${srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : '未连接'}
</span>
</div>
<div class="quick-actions">
<button class="btn btn-sm ${srv.status === 'connected' ? 'btn-danger' : 'btn-success'}" onclick="toggleConnection('${srv.id}')">
${srv.status === 'connected' ? '断开连接' : '连接服务器'}
</button>
<button class="btn btn-sm btn-outline" onclick="openServerModal('${srv.id}')">编辑配置</button>
<button class="btn btn-sm btn-outline" onclick="switchTab('subscribe')">管理订阅</button>
<button class="btn btn-sm btn-outline" onclick="switchTab('publish')">发布消息</button>
<button class="btn btn-sm btn-outline" onclick="switchTab('messages')">查看消息</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">所有服务器状态</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
${servers.map(s => `
<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);">
<span class="status-dot ${s.status}"></span>
<span style="flex:1;font-size:13px;font-weight:500;">${s.name}</span>
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-tertiary);">${s.protocol}://${s.host}:${s.port}</span>
<span class="badge badge-${s.status === 'connected' ? 'success' : s.status === 'connecting' ? 'warning' : 'info'}">
${s.status === 'connected' ? '已连接' : s.status === 'connecting' ? '连接中...' : '未连接'}
</span>
<button class="btn btn-xs ${s.status === 'connected' ? 'btn-danger' : 'btn-success'}" onclick="toggleConnection('${s.id}')">
${s.status === 'connected' ? '断开' : s.status === 'connecting' ? '...' : '连接'}
</button>
</div>
`).join('')}
</div>
</div>
`;
}
function renderTopicList(srv) {
const container = document.getElementById('topicList');
if (!srv) return;
if (srv.topics.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:20px;"><div class="empty-state-text" style="font-size:13px;">暂无订阅主题,请在上方添加</div></div>';
return;
}
container.innerHTML = srv.topics.map(t => `
<div class="topic-item ${t.subscribed ? 'subscribed' : ''}">
<span class="topic-item-text" title="${t.topic}">${t.topic}</span>
<span class="topic-item-qos">QoS ${t.qos}</span>
<button class="btn btn-xs ${t.subscribed ? 'btn-danger' : 'btn-success'}"
onclick="toggleTopicSubscription('${t.topic}')">
${t.subscribed ? '取消订阅' : '订阅'}
</button>
<button class="icon-btn danger" title="删除" onclick="removeTopic('${t.topic}')">&#10005;</button>
</div>
`).join('');
}
function renderPublishHistory(srv) {
const container = document.getElementById('publishHistory');
if (!srv) return;
if (srv.publishHistory.length === 0) {
container.innerHTML = '<div style="color:#6c7086;text-align:center;padding:20px;">暂无发布记录</div>';
return;
}
container.innerHTML = srv.publishHistory.map(m => `
<div class="msg-entry">
<span class="msg-time">${m.time}</span>
<span class="msg-direction pub">PUB</span>
<span class="msg-topic" title="${m.topic}">${m.topic}</span>
<span class="msg-payload">${escapeHtml(m.payload.length > 80 ? m.payload.slice(0, 80) + '...' : m.payload)}</span>
<span class="msg-meta">QoS${m.qos}${m.retain ? ' R' : ''}</span>
</div>
`).join('');
}
function updateTabVisibility() {
const srv = getActiveServer();
const header = document.getElementById('mainHeader');
// 无服务器时tab 仍可见但内容显示空状态
// 不做额外处理
}
// ==================== Tab 切换 ====================
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
const panel = document.getElementById('panel-' + tabName);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
if (tabName === 'messages') {
setTimeout(() => {
const log = document.getElementById('messageLog');
if (log && autoScroll) log.scrollTop = 0;
}, 50);
}
}
// ==================== 事件绑定 ====================
document.addEventListener('DOMContentLoaded', () => {
renderAll();
// Tab 切换
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
switchTab(btn.dataset.tab);
});
});
});
// ==================== 模拟消息 ====================
// 为已连接的服务器模拟接收消息
setInterval(() => {
servers.forEach(srv => {
if (srv.status === 'connected' && srv.topics.length > 0) {
const subscribedTopics = srv.topics.filter(t => t.subscribed);
if (subscribedTopics.length > 0 && Math.random() > 0.7) {
const t = subscribedTopics[Math.floor(Math.random() * subscribedTopics.length)];
const actualTopic = t.topic.replace('+', 'device' + Math.floor(Math.random() * 10));
const temp = (20 + Math.random() * 15).toFixed(1);
const hum = (40 + Math.random() * 40).toFixed(1);
srv.messages.unshift({
time: now(),
topic: actualTopic,
payload: JSON.stringify({ temperature: parseFloat(temp), humidity: parseFloat(hum), timestamp: Date.now() }),
qos: t.qos,
direction: 'sub',
id: 'msg_' + Date.now()
});
if (srv.messages.length > 200) srv.messages.pop();
saveData();
if (activeServerId === srv.id) {
renderMessages();
}
}
}
});
}, 3000);
</script>
</body>
</html>