fix: 修复6个bug + IoT设备控制增强 + DevTools IoT面板
问题1: 刷新后主对话历史不显示,侧边栏子对话列表为空 - sessionStore: 修复 setCurrentSessionId 用 Map 去重消息 - AppLayout: 修复 autoLoadNewSession 逻辑 - useWebSocket: 修复 setMessages 调用时机 问题2: 切换到次级对话后无法切换回主对话 - Sidebar: 为删除按钮添加 e.stopPropagation() 问题3&4: IoT设备列表展开导致输入栏消失 + 聊天消息无法滚动 - IoTStatusBar: 从fixed定位改为inline布局 - ChatContainer: 重构flex布局,MessageList自动撑满 问题5: AI核心无法操作IoT设备 + 无法设置温度等属性 - 新增 IoTControlTool (iot_control_tool.go) - IoTClient: 新增 ToggleDevice/SetProperty/GetHistory - 支持 set_temperature/set_brightness/set_position/set_mode/set_color 问题6: DevTools启动时Gateway代理登录异常 - devtools: 登录失败时静默降级,不阻塞启动 额外修复: - iot_tools.go: 修复fmt.Sprintf参数缺失 - iot-debug-service: 修复并发死锁问题 - DevTools: 新增IoT设备控制面板(API代理+前端UI)
This commit is contained in:
@@ -329,6 +329,76 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
|
||||
white-space: pre-wrap; word-break: break-all; color: var(--text2);
|
||||
}
|
||||
|
||||
/* IoT 设备控制面板 */
|
||||
.iot-device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||
.iot-device-card {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 16px; transition: border-color .2s;
|
||||
}
|
||||
.iot-device-card:hover { border-color: var(--accent); }
|
||||
.iot-device-card.on { border-color: var(--green); }
|
||||
.iot-device-card.off { border-color: var(--border2); opacity: .85; }
|
||||
.iot-device-header {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
|
||||
}
|
||||
.iot-device-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.iot-device-type { font-size: 10px; color: var(--text2); text-transform: uppercase; }
|
||||
.iot-device-status { display: flex; align-items: center; gap: 6px; }
|
||||
.iot-status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.iot-status-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.iot-status-dot.off { background: var(--text3); }
|
||||
.iot-device-props { margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.iot-prop-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
font-size: 12px; padding: 4px 0;
|
||||
}
|
||||
.iot-prop-label { color: var(--text2); min-width: 50px; }
|
||||
.iot-prop-value {
|
||||
font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 45px; text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
.iot-prop-control { display: flex; align-items: center; gap: 6px; flex: 1; justify-content: flex-end; }
|
||||
.iot-prop-control input[type="range"] { width: 100px; accent-color: var(--accent); }
|
||||
.iot-device-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.iot-toggle-btn {
|
||||
padding: 5px 14px; border-radius: var(--radius-sm); border: 1px solid;
|
||||
cursor: pointer; font-size: 12px; font-weight: 600; transition: all .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.iot-toggle-btn.on { background: var(--green-bg); color: var(--green); border-color: var(--green); }
|
||||
.iot-toggle-btn.on:hover { background: var(--green); color: #000; }
|
||||
.iot-toggle-btn.off { background: var(--red-bg); color: var(--red); border-color: var(--red); }
|
||||
.iot-toggle-btn.off:hover { background: var(--red); color: #fff; }
|
||||
.iot-mode-btn {
|
||||
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||||
cursor: pointer; font-size: 11px; background: var(--bg3); color: var(--text);
|
||||
transition: all .15s; font-family: inherit;
|
||||
}
|
||||
.iot-mode-btn:hover { background: var(--bg4); border-color: var(--text2); }
|
||||
.iot-mode-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
|
||||
.iot-color-btn {
|
||||
width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border);
|
||||
cursor: pointer; transition: all .15s; flex-shrink: 0;
|
||||
}
|
||||
.iot-color-btn:hover { border-color: var(--text2); transform: scale(1.15); }
|
||||
.iot-color-btn.active { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
.iot-history-panel {
|
||||
margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px;
|
||||
}
|
||||
.iot-history-item {
|
||||
font-size: 11px; color: var(--text2); padding: 3px 0; display: flex; gap: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.iot-history-item .iot-hist-time { color: var(--text3); min-width: 60px; }
|
||||
.iot-history-item .iot-hist-action { color: var(--accent); }
|
||||
.iot-history-item .iot-hist-detail { color: var(--text2); }
|
||||
.iot-refresh-bar {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.iot-last-update { font-size: 11px; color: var(--text3); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -356,6 +426,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<button class="nav-item" data-panel="performance">
|
||||
<span class="nav-icon">📊</span><span class="nav-label">性能监控</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="iot">
|
||||
<span class="nav-icon">🏠</span><span class="nav-label">IoT 设备</span>
|
||||
<span class="nav-badge" id="iot-badge" style="display:none">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="database">
|
||||
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
|
||||
<span class="nav-badge" id="db-badge" style="display:none">●</span>
|
||||
@@ -382,6 +456,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<div class="panel" id="panel-sessions"></div>
|
||||
<!-- 服务管理 -->
|
||||
<div class="panel" id="panel-services"></div>
|
||||
<!-- IoT 设备控制 -->
|
||||
<div class="panel" id="panel-iot"></div>
|
||||
<!-- 性能监控 -->
|
||||
<div class="panel" id="panel-performance"></div>
|
||||
<!-- 数据库监看 -->
|
||||
@@ -565,7 +641,7 @@ function switchPanel(name) {
|
||||
// 更新标题
|
||||
const titles = {
|
||||
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
||||
services: '🖥 服务管理', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||
};
|
||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||
|
||||
@@ -578,12 +654,13 @@ function switchPanel(name) {
|
||||
|
||||
// 渲染面板
|
||||
switch (name) {
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); break;
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1624,6 +1701,9 @@ async function tunnelAction(action) {
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="iot-panel.js"></script>
|
||||
<script>
|
||||
// ========== 初始化 ==========
|
||||
connectWS();
|
||||
refreshStatus();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// ========== IoT 设备控制面板 ==========
|
||||
function startIoTRefresh() {
|
||||
stopIoTRefresh();
|
||||
STATE.iotInterval = setInterval(function() {
|
||||
if (STATE.activePanel === 'iot') renderIoTPanel();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopIoTRefresh() {
|
||||
if (STATE.iotInterval) { clearInterval(STATE.iotInterval); STATE.iotInterval = null; }
|
||||
}
|
||||
|
||||
async function fetchIoTDevices() {
|
||||
var data = await api('/api/iot/devices');
|
||||
if (data.error) return { error: data };
|
||||
var devices = [];
|
||||
if (Array.isArray(data)) devices = data;
|
||||
else if (data.devices) devices = data.devices;
|
||||
return { devices: devices };
|
||||
}
|
||||
|
||||
var IOT_DEVICE_TYPES = {
|
||||
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
|
||||
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
|
||||
};
|
||||
|
||||
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
|
||||
var IOT_COLOR_OPTIONS = [
|
||||
{ name: '暖白', value: 'warm_white', bg: '#f5d0a9' },
|
||||
{ name: '冷白', value: 'cool_white', bg: '#d4e6fc' },
|
||||
{ name: '暖黄', value: 'warm_yellow', bg: '#fce4a6' },
|
||||
{ name: '蓝色', value: 'blue', bg: '#3b82f6' },
|
||||
{ name: '紫色', value: 'purple', bg: '#a855f7' },
|
||||
{ name: '绿色', value: 'green', bg: '#22c55e' },
|
||||
];
|
||||
|
||||
async function renderIoTPanel() {
|
||||
document.getElementById('panel-actions').innerHTML =
|
||||
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
|
||||
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
||||
|
||||
var result = await fetchIoTDevices();
|
||||
var panel = document.getElementById('panel-iot');
|
||||
|
||||
if (result.error) {
|
||||
var hint = '';
|
||||
if (result.error.errorType === 'iot_not_running') {
|
||||
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
|
||||
}
|
||||
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var devices = result.devices;
|
||||
var badge = document.getElementById('iot-badge');
|
||||
if (badge) {
|
||||
badge.textContent = devices.length;
|
||||
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
var html = '<div class="iot-refresh-bar">' +
|
||||
'<span style="font-weight:600;font-size:14px">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
||||
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
||||
'</div>';
|
||||
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderIoTDeviceCard(device) {
|
||||
var isOn = device.status === 'on';
|
||||
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
|
||||
var propsHtml = '';
|
||||
|
||||
if (device.type === 'ac') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🌡️ 温度</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
|
||||
'<span class="iot-prop-value">' + (device.temperature || 26) + '°C</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🔄 模式</span>' +
|
||||
'<div class="iot-prop-control" style="gap:4px">' +
|
||||
IOT_MODE_OPTIONS.map(function(m) {
|
||||
var active = (device.mode || 'cool') === m ? ' active' : '';
|
||||
return '<button class="iot-mode-btn' + active + '" onclick="iotSetProperty(\'' + device.id + '\', \'mode\', \'' + m + '\');refreshIoTDeviceCard(\'' + device.id + '\')">' + m + '</button>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.type === 'light') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">💡 亮度</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
||||
'<span class="iot-prop-value">' + (device.brightness || 80) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🎨 颜色</span>' +
|
||||
'<div class="iot-prop-control" style="gap:4px">' +
|
||||
IOT_COLOR_OPTIONS.map(function(c) {
|
||||
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
|
||||
return '<button class="iot-color-btn' + active + '" style="background:' + c.bg + '" title="' + c.name + '"' +
|
||||
' onclick="iotSetProperty(\'' + device.id + '\', \'color\', \'' + c.value + '\');refreshIoTDeviceCard(\'' + device.id + '\')"></button>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.type === 'curtain') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🪟 位置</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
||||
'<span class="iot-prop-value">' + (device.position != null ? device.position : 100) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.temperature != null) {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🌡️ 温度</span>' +
|
||||
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (device.battery != null) {
|
||||
propsHtml +=
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🔋 电量</span>' +
|
||||
'<span class="iot-prop-value">' + device.battery + '%</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
var actionsHtml = '<div class="iot-device-actions">' +
|
||||
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
|
||||
(isOn ? '⏻ 关闭' : '⏻ 开启') +
|
||||
'</button>';
|
||||
|
||||
if (device.type === 'ac') {
|
||||
var currentTemp = device.temperature || 26;
|
||||
actionsHtml +=
|
||||
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬇ -2°C</button>' +
|
||||
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬆ +2°C</button>';
|
||||
}
|
||||
|
||||
actionsHtml +=
|
||||
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
|
||||
'</div>' +
|
||||
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
|
||||
|
||||
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
|
||||
'<div class="iot-device-header">' +
|
||||
'<div class="iot-device-name">' +
|
||||
'<span style="font-size:24px">' + icon + '</span>' +
|
||||
'<div>' +
|
||||
'<div>' + escHtml(device.name) + '</div>' +
|
||||
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-device-status">' +
|
||||
'<span class="iot-status-dot ' + (isOn ? 'on' : 'off') + '"></span>' +
|
||||
'<span style="font-size:12px;font-weight:600;color:' + (isOn ? 'var(--green)' : 'var(--text3)') + '">' + (isOn ? '开启' : '关闭') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(propsHtml ? '<div class="iot-device-props">' + propsHtml + '</div>' : '') +
|
||||
actionsHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function iotToggle(deviceId) {
|
||||
console.log('[IoT] 切换设备开关: ' + deviceId);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/toggle', { method: 'POST' });
|
||||
if (data.error) {
|
||||
showToast('切换失败: ' + data.error, 'error');
|
||||
} else {
|
||||
var device = data.device || {};
|
||||
showToast((device.name || deviceId) + ': ' + (device.status === 'on' ? '已开启' : '已关闭'), 'success');
|
||||
renderIoTPanel();
|
||||
}
|
||||
}
|
||||
|
||||
async function iotSetProperty(deviceId, field, value) {
|
||||
console.log('[IoT] 设置设备属性: ' + deviceId + ' -> ' + field + ' = ' + value);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/set', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ field: field, value: value }),
|
||||
});
|
||||
if (data.error) {
|
||||
showToast('设置失败: ' + data.error, 'error');
|
||||
} else {
|
||||
var device = data.device || {};
|
||||
showToast((device.name || deviceId) + ': ' + field + ' = ' + value, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIoTDeviceCard(deviceId) {
|
||||
var data = await api('/api/iot/devices/' + deviceId);
|
||||
if (data.error) return;
|
||||
var device = data.device;
|
||||
if (!device) return;
|
||||
var card = document.getElementById('iot-card-' + deviceId);
|
||||
if (!card) return;
|
||||
card.outerHTML = renderIoTDeviceCard(device);
|
||||
}
|
||||
|
||||
async function iotShowHistory(deviceId) {
|
||||
var panel = document.getElementById('iot-history-' + deviceId);
|
||||
if (!panel) return;
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.log('[IoT] 获取设备历史: ' + deviceId);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/history');
|
||||
panel.style.display = '';
|
||||
if (data.error) {
|
||||
panel.innerHTML = '<div style="color:var(--red);font-size:11px;padding:4px">' + escHtml(data.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
var history = data.history || [];
|
||||
if (history.length === 0) {
|
||||
panel.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:4px">暂无操作历史</div>';
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = history.slice(-20).reverse().map(function(h) {
|
||||
var timeStr = h.timestamp ? new Date(h.timestamp).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
|
||||
return '<div class="iot-history-item">' +
|
||||
'<span class="iot-hist-time">' + timeStr + '</span>' +
|
||||
'<span class="iot-hist-action">' + escHtml(h.action || '操作') + '</span>' +
|
||||
'<span class="iot-hist-detail">' + escHtml(h.detail || h.value || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
Reference in New Issue
Block a user