fix: 第三轮修复 — 前端Session切换、DevTools UI刷新保持、头像背景替换

1. 修复前端清空对话无反应 (clearMainSessionMessages 链路)
2. 修复清除所有对话后侧边栏残留 + 重复新增按钮
3. 修复侧边栏点击无法切换会话 (Zustand 竞态 + URL hash)
4. 修复 URL 不显示 session ID (hash 同步链)
5. DevTools 会话监看刷新保持展开/折叠状态
6. 首页性能仪表盘去重 + 资源使用卡片 60s sparkline
7. DevTools 全局刷新改为 DOM 局部增量更新
8. 替换前端昔涟头像、聊天背景、用户头像为实际图片
9. 修复图片文件名 (双.png + 目录拼写)
This commit is contained in:
2026-05-17 20:32:42 +08:00
parent e7b7eff0d8
commit d00a8313ad
18 changed files with 799 additions and 343 deletions
+513 -279
View File
@@ -491,6 +491,10 @@ const STATE = {
dashboardInterval: null,
statusInterval: null,
dbInterval: null,
// 仪表盘增量刷新 (Bug 7)
dashboardRenderCount: 0,
// 资源使用 60s 滑动窗口历史 (Bug 6)
resourceHistory: {},
};
// ========== WebSocket ==========
@@ -543,6 +547,26 @@ function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function drawSparkline(canvas, data, color) {
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - ((data[i] - min) / range) * (h - 4) - 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function formatUptime(ms) {
if (!ms || ms < 0) return '—';
const s = Math.floor(ms / 1000);
@@ -701,7 +725,8 @@ function stopDbAutoRefresh() {
async function renderDashboard() {
const data = await api('/api/dashboard');
if (data.error) {
document.getElementById('panel-dashboard').innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div>`;
document.getElementById('panel-dashboard').innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
STATE.dashboardRenderCount = 0;
return;
}
STATE.dashboardData = data;
@@ -709,106 +734,167 @@ async function renderDashboard() {
const svcs = data.services?.list || {};
const runningCount = data.services?.running || 0;
const totalSvcs = data.services?.total || Object.keys(svcs).length;
const isFirstRender = STATE.dashboardRenderCount === 0;
document.getElementById('panel-dashboard').innerHTML = `
<!-- 概览统计 -->
<div class="cards-grid cards-4" style="margin-bottom:16px">
<div class="stat-card green">
<div class="stat-value">${runningCount}/${totalSvcs}</div>
<div class="stat-label">服务运行中</div>
</div>
<div class="stat-card blue">
<div class="stat-value">${data.sessions?.active ?? '—'}</div>
<div class="stat-label">活跃会话</div>
</div>
<div class="stat-card accent">
<div class="stat-value">${data.memory?.total ?? '—'}</div>
<div class="stat-label">记忆条目</div>
</div>
<div class="stat-card orange">
<div class="stat-value">${data.system?.heapUsedMB ?? '—'} MB</div>
<div class="stat-label">DevTools 内存</div>
</div>
</div>
// Bug 7: 首次渲染完整 DOM,后续只做增量更新
if (isFirstRender) {
document.getElementById('panel-dashboard').innerHTML =
'<!-- 概览统计 -->' +
'<div class="cards-grid cards-4" style="margin-bottom:16px">' +
'<div class="stat-card green">' +
'<div class="stat-value" id="stat-running">' + runningCount + '/' + totalSvcs + '</div>' +
'<div class="stat-label">服务运行中</div>' +
'</div>' +
'<div class="stat-card blue">' +
'<div class="stat-value" id="stat-sessions">' + (data.sessions?.active ?? '—') + '</div>' +
'<div class="stat-label">活跃会话</div>' +
'</div>' +
'<div class="stat-card accent">' +
'<div class="stat-value" id="stat-memory">' + (data.memory?.total ?? '—') + '</div>' +
'<div class="stat-label">记忆条目</div>' +
'</div>' +
'<div class="stat-card orange">' +
'<div class="stat-value" id="stat-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB</div>' +
'<div class="stat-label">DevTools 内存</div>' +
'</div>' +
'</div>' +
<!-- 服务状态卡片 -->
<div class="card">
<div class="card-header">
<span class="card-title">📡 服务状态</span>
<div class="quick-actions">
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
<button class="btn btn-sm" onclick="svcAction('start-all-fresh')">🔄 强制重启全部</button>
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
</div>
</div>
<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>
</div>
'<!-- 服务状态卡片 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">📡 服务状态</span>' +
'<div class="quick-actions">' +
'<button class="btn btn-sm btn-accent" onclick="svcAction(\'start-all\')">▶ 一键启动</button>' +
'<button class="btn btn-sm" onclick="svcAction(\'start-all-fresh\')">🔄 强制重启全部</button>' +
'<button class="btn btn-sm btn-red" onclick="svcAction(\'stop-all\')">⏹ 全部停止</button>' +
'</div>' +
'</div>' +
'<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>' +
'</div>' +
<!-- 数据库状态卡片 -->
<div class="card" id="db-card">
<div class="card-header">
<span class="card-title">🗄️ 数据库</span>
<span class="badge badge-stopped" id="db-status-badge">检查中...</span>
</div>
<div class="metrics">
<div class="metric"><div class="value" id="db-type-display">PostgreSQL</div><div class="label">类型</div></div>
<div class="metric"><div class="value" id="db-port-display">5432</div><div class="label">端口</div></div>
<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
<button class="btn btn-xs btn-green" onclick="controlDB('start')">▶ 启动</button>
<button class="btn btn-xs btn-red" onclick="controlDB('stop')">⏹ 停止</button>
<button class="btn btn-xs" onclick="controlDB('restart')">🔄 重启</button>
<a href="#" onclick="switchPanel('database');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>
</div>
</div>
'<!-- 数据库状态卡片 -->' +
'<div class="card" id="db-card">' +
'<div class="card-header">' +
'<span class="card-title">🗄️ 数据库</span>' +
'<span class="badge badge-stopped" id="db-status-badge">检查中...</span>' +
'</div>' +
'<div class="metrics">' +
'<div class="metric"><div class="value" id="db-type-display">PostgreSQL</div><div class="label">类型</div></div>' +
'<div class="metric"><div class="value" id="db-port-display">5432</div><div class="label">端口</div></div>' +
'<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
'</div>' +
'<div class="btn-group" style="margin-top:10px">' +
'<button class="btn btn-xs btn-green" onclick="controlDB(\'start\')">▶ 启动</button>' +
'<button class="btn btn-xs btn-red" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
'<button class="btn btn-xs" onclick="controlDB(\'restart\')">🔄 重启</button>' +
'<a href="#" onclick="switchPanel(\'database\');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>' +
'</div>' +
'</div>' +
<!-- 性能快照 + 性能仪表盘 -->
<div class="cards-grid cards-2">
<div class="card">
<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>
<div id="dashboard-perf"></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>
<div id="performance-dashboard">
<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>
</div>
</div>
</div>
'<!-- 性能快照 + 性能仪表盘 -->' +
'<div class="cards-grid cards-2">' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>' +
'<div id="dashboard-perf"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>' +
'<div id="performance-dashboard">' +
'<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>' +
'</div>' +
'</div>' +
'</div>' +
<!-- 系统信息 -->
<div class="card">
<div class="card-header"><span class="card-title">💻 系统信息</span></div>
<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap">
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
<div><span style="color:var(--text2)">堆内存:</span> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</div>
<div><span style="color:var(--text2)">总消息数:</span> ${data.sessions?.totalMessages ?? 0}</div>
<div><span style="color:var(--text2)">更新时间:</span> ${formatTime(data.timestamp)}</div>
</div>
</div>
`;
'<!-- 系统信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">💻 系统信息</span></div>' +
'<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap" id="sys-info-row">' +
'<div><span style="color:var(--text2)">运行时间:</span> <span id="sys-uptime">' + formatUptime((data.system?.uptime || 0) * 1000) + '</span></div>' +
'<div><span style="color:var(--text2)">堆内存:</span> <span id="sys-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB</span></div>' +
'<div><span style="color:var(--text2)">总消息数:</span> <span id="sys-msgs">' + (data.sessions?.totalMessages ?? 0) + '</span></div>' +
'<div><span style="color:var(--text2)">更新时间:</span> <span id="sys-time">' + formatTime(data.timestamp) + '</span></div>' +
'</div>' +
'</div>';
} else {
// Bug 7: 增量更新 — 只更新动态数值,不重建整个 DOM
var el;
el = document.getElementById('stat-running'); if (el) el.textContent = runningCount + '/' + totalSvcs;
el = document.getElementById('stat-sessions'); if (el) el.textContent = data.sessions?.active ?? '—';
el = document.getElementById('stat-memory'); if (el) el.textContent = data.memory?.total ?? '—';
el = document.getElementById('stat-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB';
// 系统信息
el = document.getElementById('sys-uptime'); if (el) el.textContent = formatUptime((data.system?.uptime || 0) * 1000);
el = document.getElementById('sys-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB';
el = document.getElementById('sys-msgs'); if (el) el.textContent = data.sessions?.totalMessages ?? 0;
el = document.getElementById('sys-time'); if (el) el.textContent = formatTime(data.timestamp);
}
// 渲染服务卡片
renderDashboardSvcCards(svcs);
// 渲染数据库卡片
// 渲染数据库卡片 (renderDBCard 本身就只更新 textContent,见 Bug 7 fix)
renderDBCard();
// 渲染性能快照
const perfContainer = document.getElementById('dashboard-perf');
const perf = data.performance?.perService || {};
perfContainer.innerHTML = Object.entries(perf).map(([id, p]) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="font-weight:500">${escapeId(id)}</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text2)">
CPU ${p.cpu || 0}% | MEM ${p.mem || 0}MB
</span>
</div>
`).join('') || '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
// Bug 6: 渲染资源使用卡片 (增量更新 + sparkline)
renderResourceUsage(data.performance?.perService || {});
// 渲染性能仪表盘
// 渲染性能仪表盘 (updatePerformanceDashboard 内联更新)
updatePerformanceDashboard(data.performance?.perService || {});
STATE.dashboardRenderCount++;
}
// ========== 资源使用卡片渲染 (Bug 6: sparkline + 增量更新) ==========
function renderResourceUsage(perfData) {
const container = document.getElementById('dashboard-perf');
if (!container) return;
const entries = Object.entries(perfData);
const MAX_HISTORY = 60;
const firstRender = STATE.dashboardRenderCount === 0;
// 首次渲染: 创建完整 DOM 结构 (含 canvas)
if (firstRender || entries.length > 0 && !container.querySelector('.resource-row')) {
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
return;
}
container.innerHTML = entries.map(function (kv) {
const id = kv[0], p = kv[1];
return '<div class="resource-row" data-svc="' + id + '" style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);gap:8px">' +
'<span style="font-weight:500;min-width:70px">' + escapeId(id) + '</span>' +
'<canvas id="sparkline-' + id + '" width="120" height="28" style="flex-shrink:0"></canvas>' +
'<span class="resource-val" style="font-family:\'JetBrains Mono\',monospace;font-size:12px;color:var(--text2);min-width:110px;text-align:right">' +
'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB' +
'</span>' +
'</div>';
}).join('');
}
// 增量更新: 只更新数值和 sparkline
const rows = container.querySelectorAll('.resource-row');
rows.forEach(function (row) {
const svcId = row.getAttribute('data-svc');
const p = perfData[svcId];
if (!p) return;
// 更新数值
const valEl = row.querySelector('.resource-val');
if (valEl) valEl.textContent = 'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB';
// 更新 60s 滑动窗口历史
if (!STATE.resourceHistory[svcId]) STATE.resourceHistory[svcId] = { cpu: [], mem: [] };
var h = STATE.resourceHistory[svcId];
h.cpu.push(p.cpu || 0);
h.mem.push(p.mem || 0);
if (h.cpu.length > MAX_HISTORY) h.cpu.shift();
if (h.mem.length > MAX_HISTORY) h.mem.shift();
// 绘制 CPU sparkline
var cpuCanvas = document.getElementById('sparkline-' + svcId);
if (cpuCanvas) drawSparkline(cpuCanvas, h.cpu, '#3b82f6');
});
}
// ========== 性能仪表盘渲染 ==========
@@ -859,59 +945,73 @@ async function updatePerformanceDashboard(perfData) {
}
} catch { /* 忽略: 使用本地计算的数据 */ }
container.innerHTML = `
<div class="perf-dashboard">
<!-- CPU 使用率 -->
<div class="perf-row">
<span class="perf-label">🖥 CPU</span>
<div class="perf-bar-wrap">
<div class="perf-bar ${cpuLevel}" style="width:${Math.min(avgCpu, 100)}%"></div>
</div>
<span class="perf-value">${avgCpu}% ${trendCpu}</span>
</div>
// Bug 7: 增量更新 — 首次创建 DOM 结构,后续只更新数值
const isFirstRender = !container.querySelector('.perf-dashboard');
if (isFirstRender) {
container.innerHTML =
'<div class="perf-dashboard">' +
'<!-- CPU 使用率 -->' +
'<div class="perf-row">' +
'<span class="perf-label">🖥 CPU</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + cpuLevel + '" id="perf-cpu-bar" style="width:' + Math.min(avgCpu, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-cpu-val">' + avgCpu + '% ' + trendCpu + '</span>' +
'</div>' +
<!-- 内存使用 -->
<div class="perf-row">
<span class="perf-label">💾 内存</span>
<div class="perf-bar-wrap">
<div class="perf-bar ${memLevel}" style="width:${Math.min(totalMem / 1024 * 100, 100)}%"></div>
</div>
<span class="perf-value">${Math.round(totalMem)} MB ${trendMem}</span>
</div>
'<!-- 内存使用 -->' +
'<div class="perf-row">' +
'<span class="perf-label">💾 内存</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + memLevel + '" id="perf-mem-bar" style="width:' + Math.min(totalMem / 1024 * 100, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-mem-val">' + Math.round(totalMem) + ' MB ' + trendMem + '</span>' +
'</div>' +
<!-- 详细统计 -->
<div style="margin-top:8px">
<div class="perf-stat">
<span class="perf-stat-icon">⏱</span>
<span class="perf-stat-label">平均请求延迟</span>
<span class="perf-stat-value" style="color:var(--yellow)">${avgLatency}</span>
</div>
<div class="perf-stat">
<span class="perf-stat-icon">🔗</span>
<span class="perf-stat-label">活跃连接数</span>
<span class="perf-stat-value" style="color:var(--accent)">${activeCount}</span>
</div>
<div class="perf-stat">
<span class="perf-stat-icon">📦</span>
<span class="perf-stat-label">监控服务数</span>
<span class="perf-stat-value" style="color:var(--blue)">${entries.length}</span>
</div>
</div>
'<!-- 详细统计 -->' +
'<div style="margin-top:8px">' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">⏱</span>' +
'<span class="perf-stat-label">平均请求延迟</span>' +
'<span class="perf-stat-value" id="perf-latency" style="color:var(--yellow)">' + avgLatency + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">🔗</span>' +
'<span class="perf-stat-label">活跃连接数</span>' +
'<span class="perf-stat-value" id="perf-conns" style="color:var(--accent)">' + activeCount + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">📦</span>' +
'<span class="perf-stat-label">监控服务数</span>' +
'<span class="perf-stat-value" id="perf-svcs" style="color:var(--blue)">' + entries.length + '</span>' +
'</div>' +
'</div>' +
'</div>';
} else {
// 增量更新: 只更新数值
var cpuBar = document.getElementById('perf-cpu-bar');
if (cpuBar) {
cpuBar.className = 'perf-bar ' + cpuLevel;
cpuBar.style.width = Math.min(avgCpu, 100) + '%';
}
var cpuVal = document.getElementById('perf-cpu-val');
if (cpuVal) cpuVal.textContent = avgCpu + '% ' + trendCpu;
<!-- 各服务简要 -->
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px">
${entries.map(([id, p]) => `
<div class="perf-stat">
<span class="perf-stat-icon" style="font-size:12px">${p.pid ? '🟢' : '🔴'}</span>
<span class="perf-stat-label">${escapeId(id)}</span>
<span class="perf-stat-value" style="font-size:11px;color:var(--text2)">
CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB
</span>
</div>
`).join('')}
</div>
</div>
`;
var memBar = document.getElementById('perf-mem-bar');
if (memBar) {
memBar.className = 'perf-bar ' + memLevel;
memBar.style.width = Math.min(totalMem / 1024 * 100, 100) + '%';
}
var memVal = document.getElementById('perf-mem-val');
if (memVal) memVal.textContent = Math.round(totalMem) + ' MB ' + trendMem;
var latEl = document.getElementById('perf-latency');
if (latEl) latEl.textContent = avgLatency;
var connEl = document.getElementById('perf-conns');
if (connEl) connEl.textContent = activeCount;
var svcEl = document.getElementById('perf-svcs');
if (svcEl) svcEl.textContent = entries.length;
}
}
function renderDashboardSvcCards(svcs) {
@@ -1192,17 +1292,30 @@ async function fetchActiveSessions() {
if (Object.keys(users).length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
STATE.expandedSessions = [];
return;
}
// Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复
const previouslyExpanded = [];
const existingDetails = container.querySelectorAll('[id^="session-detail-"]');
existingDetails.forEach(function(el) {
if (el.style.display !== 'none') {
const match = el.id.match(/^session-detail-(\d+)$/);
if (match) previouslyExpanded.push(parseInt(match[1]));
}
});
let html = '';
let globalIndex = 0;
const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开
for (const [userID, sessions] of Object.entries(users)) {
html += `<div style="margin-bottom:16px">`;
html += `<div style="font-weight:600;font-size:14px;padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;color:var(--accent)">👤 User: ${escHtml(userID)}</div>`;
for (const s of sessions) {
const idx = globalIndex++;
flatSessionMap.push({ index: idx, session: s, userID: userID });
html += `
<div style="padding:6px 0 6px 20px">
<div id="session-row-${idx}" class="session-row" data-index="${idx}" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm)" onclick="toggleSessionDetail(${idx})">
@@ -1223,6 +1336,104 @@ async function fetchActiveSessions() {
}
container.innerHTML = html;
// Bug 5: 恢复之前展开的 session (通过 session_id 匹配新旧 index)
if (previouslyExpanded.length > 0) {
// 构建 session_id -> 旧 index 的映射
const oldSessionIdToIndex = {};
STATE.sessionsData.forEach(function(s, i) { oldSessionIdToIndex[s.session_id] = i; });
// 对每个之前展开的 index,找到对应的 session_id,再找到新的 index
const toExpandNewIndices = [];
previouslyExpanded.forEach(function(oldIdx) {
const oldSession = STATE.sessionsData[oldIdx];
if (oldSession) {
const sid = oldSession.session_id;
// 在新的 flatSessionMap 中按 session_id 查找
for (let j = 0; j < flatSessionMap.length; j++) {
if (flatSessionMap[j].session.session_id === sid) {
toExpandNewIndices.push(j);
break;
}
}
}
});
// 恢复展开
toExpandNewIndices.forEach(function(newIdx) {
restoreSessionExpand(newIdx);
});
}
}
// Bug 5 helper: 恢复展开的 session UI 并自动加载详情
async function restoreSessionExpand(index) {
const detailRow = document.getElementById('session-detail-' + index);
const arrow = document.getElementById('session-arrow-' + index);
if (!detailRow || !arrow) return;
detailRow.style.display = '';
arrow.classList.add('open');
// 直接加载详情内容 (不调用 toggleSessionDetail 以避免 flip-flop)
const session = STATE.sessionsData[index];
if (!session) return;
const contentEl = document.getElementById('session-detail-content-' + index);
if (!contentEl) return;
await loadSessionDetailContent(session, contentEl);
}
// 提取 session 详情加载逻辑为独立函数 (Bug 5 复用)
async function loadSessionDetailContent(session, contentEl) {
const data = await api('/api/sessions/' + session.session_id);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
}
contentEl.innerHTML = '<div style="color:var(--red);text-align:center">' + escHtml(data.error) + hint + '</div>';
return;
}
const messages = data.recent_messages || [];
contentEl.innerHTML =
'<div class="detail-row">' +
'<span class="detail-label">会话ID:</span>' +
'<code style="font-size:11px">' + escHtml(data.session_id || session.session_id) + '</code>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">用户ID:</span>' +
'<span>' + escHtml(data.user_id || session.user_id) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">状态:</span>' +
'<span class="badge ' + statusBadge(data.state || 'idle') + '">' + (data.state || 'idle') + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">消息数:</span>' +
'<span>' + (data.message_count || 0) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">连接时间:</span>' +
'<span>' + formatTime(data.connected_at) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">最后活跃:</span>' +
'<span>' + formatTime(data.last_activity) + '</span>' +
'</div>' +
(messages.length > 0 ?
'<div style="margin-top:10px;font-weight:600;font-size:12px;color:var(--text2)">📝 最近消息 (' + messages.length + ')</div>' +
'<div class="msg-list">' +
messages.map(function(m) {
return '<div class="msg-item">' +
'<span class="role ' + m.role + '">' + m.role + '</span>' +
'<span style="color:var(--text2);font-size:10px;margin-right:6px">' + formatTime(m.timestamp) + '</span>' +
escHtml(m.content || '') +
'</div>';
}).join('') +
'</div>'
: '<div style="margin-top:8px;color:var(--text2);font-size:12px">暂无消息记录</div>');
}
// 保留旧 loadSessions 兼容其他调用
@@ -1231,9 +1442,9 @@ async function loadSessions() {
}
async function toggleSessionDetail(index) {
const detailRow = document.getElementById(`session-detail-${index}`);
const arrow = document.getElementById(`session-arrow-${index}`);
const contentEl = document.getElementById(`session-detail-content-${index}`);
const detailRow = document.getElementById('session-detail-' + index);
const arrow = document.getElementById('session-arrow-' + index);
const contentEl = document.getElementById('session-detail-content-' + index);
if (detailRow.style.display !== 'none') {
// 折叠
@@ -1249,61 +1460,8 @@ async function toggleSessionDetail(index) {
const session = STATE.sessionsData[index];
if (!session) return;
// 获取详情
const data = await api(`/api/sessions/${session.session_id}`);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
}
contentEl.innerHTML = `<div style="color:var(--red);text-align:center">${escHtml(data.error)}${hint}</div>`;
return;
}
const messages = data.recent_messages || [];
contentEl.innerHTML = `
<div class="detail-row">
<span class="detail-label">会话ID:</span>
<code style="font-size:11px">${escHtml(data.session_id || session.session_id)}</code>
</div>
<div class="detail-row">
<span class="detail-label">用户ID:</span>
<span>${escHtml(data.user_id || session.user_id)}</span>
</div>
<div class="detail-row">
<span class="detail-label">状态:</span>
<span class="badge ${statusBadge(data.state || 'idle')}">${data.state || 'idle'}</span>
</div>
<div class="detail-row">
<span class="detail-label">消息数:</span>
<span>${data.message_count || 0}</span>
</div>
<div class="detail-row">
<span class="detail-label">连接时间:</span>
<span>${formatTime(data.connected_at)}</span>
</div>
<div class="detail-row">
<span class="detail-label">最后活跃:</span>
<span>${formatTime(data.last_activity)}</span>
</div>
${messages.length > 0 ? `
<div style="margin-top:10px;font-weight:600;font-size:12px;color:var(--text2)">📝 最近消息 (${messages.length})</div>
<div class="msg-list">
${messages.map(m => `
<div class="msg-item">
<span class="role ${m.role}">${m.role}</span>
<span style="color:var(--text2);font-size:10px;margin-right:6px">${formatTime(m.timestamp)}</span>
${escHtml(m.content || '')}
</div>
`).join('')}
</div>
` : '<div style="margin-top:8px;color:var(--text2);font-size:12px">暂无消息记录</div>'}
`;
// 委托给共用函数
await loadSessionDetailContent(session, contentEl);
}
// ========== 面板4: 服务管理 ==========
@@ -1529,21 +1687,36 @@ function renderPerfPanels(snap) {
if (!container) return;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
container.innerHTML = ids.map(id => {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return `
<div class="card" style="margin:0">
<div class="card-header">
<span class="card-title">${escapeId(id)}</span>
<span style="font-size:11px;color:var(--text2)">CPU ${s.cpu}% | MEM ${s.mem}MB</span>
</div>
<div class="chart-container">
<svg viewBox="0 0 300 120" class="chart-svg">
${drawChart(STATE.perfHistory[id] || [])}
</svg>
</div>
</div>`;
}).join('');
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
const isFirstRender = !container.querySelector('.perf-card');
if (isFirstRender) {
container.innerHTML = ids.map(function(id) {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return '<div class="card perf-card" style="margin:0" data-svc="' + id + '">' +
'<div class="card-header">' +
'<span class="card-title">' + escapeId(id) + '</span>' +
'<span style="font-size:11px;color:var(--text2)" class="perf-snap-val">CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB</span>' +
'</div>' +
'<div class="chart-container">' +
'<svg viewBox="0 0 300 120" class="chart-svg" id="perf-chart-' + id + '">' +
drawChart(STATE.perfHistory[id] || []) +
'</svg>' +
'</div>' +
'</div>';
}).join('');
} else {
// 增量更新: 只更新 SVG 图表内容和快照数值
ids.forEach(function(id) {
var card = container.querySelector('.perf-card[data-svc="' + id + '"]');
if (!card) return;
var s = snap[id] || { pid: null, cpu: 0, mem: 0 };
var valEl = card.querySelector('.perf-snap-val');
if (valEl) valEl.textContent = 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB';
var svg = card.querySelector('.chart-svg');
if (svg) svg.innerHTML = drawChart(STATE.perfHistory[id] || []);
});
}
}
function drawChart(history) {
@@ -1582,14 +1755,14 @@ async function fetchDatabaseStatus() {
async function renderDatabasePanel() {
const data = await fetchDatabaseStatus();
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
`;
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>' +
'<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>';
const panel = document.getElementById('panel-database');
if (data.error) {
panel.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div>`;
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
STATE.dbInitialized = false;
return;
}
@@ -1614,71 +1787,132 @@ async function renderDatabasePanel() {
}
}
panel.innerHTML = `
<!-- 概览 -->
<div class="card">
<div class="card-header">
<span class="card-title">🔌 SSH 隧道状态</span>
<span class="badge ${tunnelRunning ? 'badge-running' : 'badge-stopped'}">${tunnelRunning ? '运行中' : '未运行'}</span>
</div>
<div class="db-summary">
<div class="db-summary-stat">
<div class="val" style="color:${allAlive ? 'var(--green)' : 'var(--red)'}">${aliveCount}/${totalPorts}</div>
<div class="lbl">数据库端口通联</div>
</div>
${pg ? `
<div class="db-summary-stat">
<div class="val" style="color:var(--blue)">${pg.memories ?? '—'}</div>
<div class="lbl">记忆条目 (${escHtml(pg.database || '')})</div>
</div>
` : ''}
<div class="db-summary-stat">
<div class="val" style="color:var(--text2)">${formatTime(data.timestamp)}</div>
<div class="lbl">最后检查时间</div>
</div>
</div>
<div class="db-grid">
${ports.map(p => `
<div class="db-port-card ${p.alive ? 'alive' : 'dead'}">
<div class="db-dot"></div>
<div class="db-info">
<div class="db-name">${escHtml(p.name)}</div>
<div class="db-port-label">:${p.port} ${p.alive ? '✅' : '❌'}</div>
</div>
</div>
`).join('')}
</div>
</div>
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素
const isFirstRender = !STATE.dbInitialized;
if (isFirstRender) {
panel.innerHTML =
'<!-- 概览 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">🔌 SSH 隧道状态</span>' +
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</span>' +
'</div>' +
'<div class="db-summary">' +
'<div class="db-summary-stat">' +
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
'<div class="lbl">数据库端口通联</div>' +
'</div>' +
(pg ?
'<div class="db-summary-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">' + (pg.memories ?? '') + '</div>' +
'<div class="lbl">记忆条目 (' + escHtml(pg.database || '') + ')</div>' +
'</div>'
: '<div class="db-summary-stat" style="display:none" id="db-mem-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">—</div>' +
'<div class="lbl">记忆条目</div>' +
'</div>') +
'<div class="db-summary-stat">' +
'<div class="val" id="db-check-time" style="color:var(--text2)">' + formatTime(data.timestamp) + '</div>' +
'<div class="lbl">最后检查时间</div>' +
'</div>' +
'</div>' +
'<div class="db-grid" id="db-ports-grid">' +
ports.map(function(p) {
return '<div class="db-port-card ' + (p.alive ? 'alive' : 'dead') + '" data-port="' + p.port + '">' +
'<div class="db-dot"></div>' +
'<div class="db-info">' +
'<div class="db-name">' + escHtml(p.name) + '</div>' +
'<div class="db-port-label">:' + p.port + ' ' + (p.alive ? '✅' : '❌') + '</div>' +
'</div>' +
'</div>';
}).join('') +
'</div>' +
'</div>' +
<!-- 隧道操作 -->
<div class="card">
<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>
<div class="btn-group" style="margin-bottom:8px">
<button class="btn btn-green btn-sm" onclick="tunnelAction('start')" ${tunnelRunning && allAlive ? 'disabled' : ''}>▶ 启动隧道</button>
<button class="btn btn-red btn-sm" onclick="tunnelAction('stop')" ${!tunnelRunning ? 'disabled' : ''}>⏹ 停止隧道</button>
<button class="btn btn-sm" onclick="tunnelAction('restart')">🔄 重启隧道</button>
<button class="btn btn-sm" onclick="tunnelAction('status')">📋 查看状态</button>
</div>
${tunnelRunning && !allAlive ? '<div style="font-size:11px;color:var(--yellow);margin-bottom:8px">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' : ''}
<div id="tunnel-log-container" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:11px;color:var(--text2)">操作日志</span>
<button class="btn btn-xs" onclick="document.getElementById('tunnel-log-container').style.display='none'">✕</button>
</div>
<div class="tunnel-log" id="tunnel-log"></div>
</div>
</div>
'<!-- 隧道操作 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>' +
'<div class="btn-group" style="margin-bottom:8px">' +
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="tunnelAction(\'stop\')"' + (!tunnelRunning ? ' disabled' : '') + '>⏹ 停止隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'restart\')">🔄 重启隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
'</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (tunnelRunning && !allAlive ? 'block' : 'none') + '">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' +
'<div id="tunnel-log-container" style="display:none">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
'<button class="btn btn-xs" onclick="document.getElementById(\'tunnel-log-container\').style.display=\'none\'">✕</button>' +
'</div>' +
'<div class="tunnel-log" id="tunnel-log"></div>' +
'</div>' +
'</div>' +
<!-- 数据库连接信息 -->
<div class="card">
<div class="card-header"><span class="card-title">📋 连接说明</span></div>
<div style="font-size:12px;color:var(--text2);line-height:1.8">
<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>
<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>
<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>
</div>
</div>
`;
'<!-- 数据库连接信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
'<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>' +
'<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
'</div>' +
'</div>';
STATE.dbInitialized = true;
} else {
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
var el;
// 隧道状态徽章
el = document.getElementById('db-tunnel-badge');
if (el) {
el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped');
el.textContent = tunnelRunning ? '运行中' : '未运行';
}
// 端口通联计数
el = document.getElementById('db-alive-count');
if (el) {
el.textContent = aliveCount + '/' + totalPorts;
el.style.color = allAlive ? 'var(--green)' : 'var(--red)';
}
// 记忆条目
var memStat = document.getElementById('db-mem-stat');
el = document.getElementById('db-mem-count');
if (pg) {
if (memStat) memStat.style.display = '';
if (el) el.textContent = pg.memories ?? '—';
} else {
if (memStat) memStat.style.display = 'none';
}
// 检查时间
el = document.getElementById('db-check-time');
if (el) el.textContent = formatTime(data.timestamp);
// 更新端口卡片
var grid = document.getElementById('db-ports-grid');
if (grid) {
ports.forEach(function(p) {
var card = grid.querySelector('.db-port-card[data-port="' + p.port + '"]');
if (card) {
card.className = 'db-port-card ' + (p.alive ? 'alive' : 'dead');
var lbl = card.querySelector('.db-port-label');
if (lbl) lbl.textContent = ':' + p.port + ' ' + (p.alive ? '✅' : '❌');
}
});
}
// 更新按钮 disable 状态
el = document.getElementById('db-tunnel-start');
if (el) el.disabled = !!(tunnelRunning && allAlive);
el = document.getElementById('db-tunnel-stop');
if (el) el.disabled = !tunnelRunning;
// 僵尸警告
el = document.getElementById('db-zombie-warn');
if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none';
}
}
function refreshDatabasePanel() {
+131 -10
View File
@@ -35,19 +35,21 @@ var IOT_COLOR_OPTIONS = [
];
async function renderIoTPanel() {
var result = await fetchIoTDevices();
var panel = document.getElementById('panel-iot');
// 更新操作栏 (只更新时间戳文本)
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>';
STATE.iotInitialized = false;
return;
}
@@ -68,14 +70,133 @@ async function renderIoTPanel() {
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>';
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
var grid = document.getElementById('iot-device-grid');
var firstRender = !STATE.iotInitialized;
panel.innerHTML = html;
if (firstRender || !grid) {
// 首次渲染: 创建完整结构
var html = '<div class="iot-refresh-bar">' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 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;
STATE.iotInitialized = true;
} else {
// 增量更新: 只更新设备数量、最后更新时间
var countEl = document.getElementById('iot-device-count');
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
// 对每个已有设备卡片做增量更新
devices.forEach(function(device) {
updateIoTDeviceCardInPlace(device);
});
}
}
// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM
function updateIoTDeviceCardInPlace(device) {
var card = document.getElementById('iot-card-' + device.id);
if (!card) return;
var isOn = device.status === 'on';
// 更新卡片 class (on/off 边框)
card.className = 'iot-device-card ' + (isOn ? 'on' : 'off');
// 更新状态圆点和文字
var statusDot = card.querySelector('.iot-status-dot');
if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off');
var statusText = card.querySelector('.iot-device-status span:last-child');
if (statusText) {
statusText.textContent = isOn ? '开启' : '关闭';
statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)';
}
// 更新开关按钮
var toggleBtn = card.querySelector('.iot-toggle-btn');
if (toggleBtn) {
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
}
// 更新设备属性值 (温度、亮度、位置等)
var propValues = card.querySelectorAll('.iot-prop-value');
var type = device.type;
if (type === 'ac') {
// AC: 温度 slider + 模式按钮
var tempSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (tempSlider) {
tempSlider.value = device.temperature || 26;
var tempVal = tempSlider.nextElementSibling;
if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C';
}
// 更新模式按钮
var modeBtns = card.querySelectorAll('.iot-mode-btn');
modeBtns.forEach(function(btn) {
var btnMode = btn.textContent.trim();
if (btnMode === (device.mode || 'cool')) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
} else if (type === 'light') {
var brightSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (brightSlider) {
brightSlider.value = device.brightness || 80;
var brightVal = brightSlider.nextElementSibling;
if (brightVal) brightVal.textContent = (device.brightness || 80) + '%';
}
// 更新颜色按钮
var colorBtns = card.querySelectorAll('.iot-color-btn');
colorBtns.forEach(function(btn) {
// 颜色值从 onclick 属性解析
var onclick = btn.getAttribute('onclick') || '';
var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/);
if (match && match[1] === (device.color || 'warm_white')) {
btn.classList.add('active');
} else if (!match || match[1] !== (device.color || 'warm_white')) {
btn.classList.remove('active');
}
});
} else if (type === 'curtain') {
var posSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (posSlider) {
posSlider.value = device.position != null ? device.position : 100;
var posVal = posSlider.nextElementSibling;
if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%';
}
} else if (device.temperature != null) {
// sensor/thermostat: 只读温度
if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C');
}
// 更新电量
var batteryEls = card.querySelectorAll('.iot-prop-value');
batteryEls.forEach(function(el) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
if (device.battery != null) el.textContent = device.battery + '%';
}
});
// 更新 AC 温度按钮的 onclick 引用
if (type === 'ac') {
var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs');
acBtns.forEach(function(btn) {
var text = btn.textContent.trim();
var currentTemp = device.temperature || 26;
if (text === '⬇ -2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
} else if (text === '⬆ +2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
}
});
}
}
function renderIoTDeviceCard(device) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+2
View File
@@ -64,6 +64,7 @@ export default function App() {
const found = currentSessions.find((s) => s.id === hashId);
if (found) {
setCurrentSessionId(found.id);
setHashSessionId(found.id);
await loadMessagesFromServer(found.id);
return;
}
@@ -73,6 +74,7 @@ export default function App() {
if (resp.messages && resp.messages.length > 0) {
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
setCurrentSessionId(hashId);
setHashSessionId(hashId);
const msgs = resp.messages.map((m: any, i: number) => ({
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
role: m.role,
@@ -12,7 +12,7 @@ export function ChatContainer() {
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-col h-full overflow-hidden chat-background">
{/* 状态指示器栏 */}
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20 flex-shrink-0">
<div className="flex items-center gap-2">
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { useAuthStore } from '@/store/authStore';
interface MessageBubbleProps {
role: 'user' | 'assistant' | 'system';
@@ -120,12 +121,35 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
)}
</div>
{/* 用户头像占位 */}
{isUser && (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
<span className="text-white text-sm"></span>
</div>
)}
{/* 用户头像 */}
{isUser && <UserAvatar />}
</div>
);
}
/** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */
function UserAvatar() {
const [imgError, setImgError] = useState(false);
const userId = useAuthStore((s) => s.userId);
const isAdmin = userId?.startsWith('admin_') ?? false;
const avatarSrc = isAdmin
? '/images/User_Avatar/Admin_Avatar.jpg'
: '/images/User_Avatar/Default_Avatar.png';
if (imgError) {
return (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
<span className="text-white text-sm"></span>
</div>
);
}
return (
<img
src={avatarSrc}
alt={isAdmin ? '管理员' : '用户'}
className="w-8 h-8 rounded-full object-cover flex-shrink-0 mt-1 shadow-sm"
onError={() => setImgError(true)}
/>
);
}
@@ -1,12 +1,39 @@
import { useState } from 'react';
import { usePersonaStore } from '@/store/personaStore';
import type { CyreneForm } from '@/types/persona';
import type { CyreneForm, Mood } from '@/types/persona';
interface CyreneAvatarProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const FORM_AVATAR: Record<CyreneForm, string> = {
/**
* 根据昔涟形态和心情获取对应的头像图片路径。
*
* 图片目录结构:
* - 1st_Form/ (空 — 尚未收集,回退到 2nd_Form)
* - 2nd_Form/: Cyrene-2F-N-Happy-1.png, Cyrene-2F-N-Tangle-1.png
* - 3rd_Form/: Cyrene-3F-Q-Happy-1.png, Cyrene-3F-Q-Happy-2.png
*/
function getAvatarPath(form: CyreneForm, _mood: Mood): string {
const base = '/images/Cyrene_Avatar';
// de_moi_ge 形态使用 3rd_Form,其余形态(mimi/default)使用 2nd_Form
// 注意:1st_Form 目录为空,mimi 也回退到 2nd_Form
if (form === 'de_moi_ge') {
return `${base}/3rd_Form/Cyrene-3F-Q-Happy-1.png`;
}
// mimi / default 形态:happy 用 Happy 图,thoughtful/worried 用 Tangle 图
if (_mood === 'thoughtful' || _mood === 'worried') {
return `${base}/2nd_Form/Cyrene-2F-N-Tangle-1.png`;
}
return `${base}/2nd_Form/Cyrene-2F-N-Happy-1.png`;
}
/** 图片加载失败时的回退 emoji */
const FORM_FALLBACK_EMOJI: Record<CyreneForm, string> = {
mimi: '🌸',
default: '🌺',
de_moi_ge: '🌌',
@@ -18,18 +45,35 @@ const SIZE_CLASS = {
lg: 'w-20 h-20 text-4xl',
};
const FORM_LABEL: Record<CyreneForm, string> = {
mimi: '迷迷',
default: '小昔涟',
de_moi_ge: '德谬歌',
};
export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) {
const { currentForm } = usePersonaStore();
const emoji = FORM_AVATAR[currentForm] || '🌸';
const { currentForm, mood } = usePersonaStore();
const [imgError, setImgError] = useState(false);
const avatarSrc = getAvatarPath(currentForm, mood);
const fallbackEmoji = FORM_FALLBACK_EMOJI[currentForm] || '🌸';
return (
<div
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md ${className}`}
title={`昔涟 · ${currentForm === 'mimi' ? '迷迷' : currentForm === 'de_moi_ge' ? '德谬歌' : '小昔涟'}`}
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md overflow-hidden ${className}`}
title={`昔涟 · ${FORM_LABEL[currentForm]}`}
>
<span role="img" aria-label="昔涟">
{emoji}
</span>
{imgError ? (
<span role="img" aria-label="昔涟">
{fallbackEmoji}
</span>
) : (
<img
src={avatarSrc}
alt={`昔涟 · ${FORM_LABEL[currentForm]}`}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
)}
</div>
);
}
+13 -1
View File
@@ -1,9 +1,19 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
import { useChatStore } from '@/store/chatStore';
import { createSession as apiCreateSession } from '@/api/sessions';
import type { Session } from '@/types/session';
const SESSION_HASH_PREFIX = 'session=';
function setHashSessionId(sessionId: string | null) {
if (sessionId) {
window.location.hash = SESSION_HASH_PREFIX + sessionId;
} else {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
/** 生成简易随机ID */
function randomID(n: number = 12): string {
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
@@ -73,6 +83,7 @@ export function useSession() {
};
addSession(newSession);
setCurrentSessionId(newSession.id);
setHashSessionId(newSession.id);
return newSession;
}
return null;
@@ -111,6 +122,7 @@ export function useSession() {
const setCurrentSession = useCallback(
async (id: string) => {
setCurrentSessionId(id);
setHashSessionId(id);
// 加载该会话的消息历史
await loadMessagesFromServer(id);
},
+2 -31
View File
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { getToken } from '@/api/client';
import { fetchMessages } from '@/api/sessions';
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
const WS_BASE_URL =
@@ -14,7 +13,6 @@ export function useWebSocket() {
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
const activeSessionRef = useRef<string | null>(null);
const loadingRef = useRef(false); // 防止重复加载消息
// 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId);
@@ -83,38 +81,11 @@ export function useWebSocket() {
wsRef.current = ws;
}, []);
// 会话切换时:先通过 REST API 加载历史消息,再建立 WS 连接
// 会话切换时:重建 WebSocket 连接(消息历史由 useSession.setCurrentSession 负责加载)
useEffect(() => {
activeSessionRef.current = currentSessionId;
const loadAndConnect = async () => {
// 如果是从 URL 恢复的 session,先加载历史消息
if (currentSessionId && !loadingRef.current) {
loadingRef.current = true;
try {
const resp = await fetchMessages(currentSessionId);
const rawMessages = resp.messages || [];
const msgs = rawMessages.map((m: any, i: number) => ({
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
role: m.role,
content: m.content,
timestamp:
typeof m.created_at === 'number' ? m.created_at : Date.now(),
isStreaming: false,
}));
useSessionStore.getState().setMessages(msgs);
useChatStore.getState().setMessages(msgs);
} catch {
// 加载失败不影响后续连接
} finally {
loadingRef.current = false;
}
}
connect();
};
loadAndConnect();
connect();
return () => {
if (reconnectTimerRef.current) {
+15
View File
@@ -103,6 +103,21 @@
margin-left: 2px;
}
/* ===== 聊天背景 ===== */
.chat-background {
background-image: url('/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png');
background-size: cover;
background-position: center;
background-attachment: fixed;
}
/* 横屏时使用 Landscape 背景 */
@media (orientation: landscape) {
.chat-background {
background-image: url('/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png');
}
}
/* 深色模式 */
@media (prefers-color-scheme: dark) {
body {
+39 -6
View File
@@ -66,9 +66,10 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
messages: state.currentSessionId === id ? [] : state.messages,
})),
setCurrentSessionId: (id) => {
const oldId = get().currentSessionId;
set({ currentSessionId: id });
// 切换会话时清空旧消息,等待加载
if (id !== get().currentSessionId) {
if (id !== oldId) {
set({ messages: [], loading: true });
useChatStore.getState().clearMessages();
}
@@ -141,22 +142,31 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
if (!ok) return;
const state = get();
const remaining = state.sessions.filter((s) => s.id !== id);
const wasCurrent = state.currentSessionId === id;
// 更新本地列表
set({ sessions: remaining });
// 从服务端重新加载会话列表,保证侧边栏同步
await get().loadSessionsFromServer(userId);
const refreshed = get().sessions;
if (wasCurrent) {
if (remaining.length > 0) {
if (refreshed.length > 0) {
// 切换到列表中的第一个会话
const nextId = remaining[0].id;
const nextId = refreshed[0].id;
set({ currentSessionId: nextId });
await get().loadMessagesFromServer(nextId);
} else {
// 没有会话了:管理员回到主对话,普通用户创建新对话
set({ currentSessionId: null, messages: [] });
useChatStore.getState().clearMessages();
// 管理员:自动创建主对话
if (isAdminUser(userId)) {
const mainSession = await get().ensureMainSession(userId);
if (mainSession) {
set({ currentSessionId: mainSession.id });
useChatStore.getState().clearMessages();
}
}
}
}
},
@@ -168,8 +178,31 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
const ok = await apiDeleteAllSessions(userId);
if (!ok) return;
// 从服务端重新加载会话列表
await get().loadSessionsFromServer(userId);
const refreshed = get().sessions;
if (refreshed.length > 0) {
// 服务端仍有会话(不应该发生,但做防御性处理)
const latest = refreshed[0];
set({ currentSessionId: latest.id, messages: [] });
useChatStore.getState().clearMessages();
await get().loadMessagesFromServer(latest.id);
return;
}
// 所有会话已删除
set({ sessions: [], currentSessionId: null, messages: [] });
useChatStore.getState().clearMessages();
// 管理员:自动创建主对话
if (isAdminUser(userId)) {
const mainSession = await get().ensureMainSession(userId);
if (mainSession) {
set({ currentSessionId: mainSession.id });
useChatStore.getState().clearMessages();
}
}
},
/**