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 + 目录拼写)
@@ -491,6 +491,10 @@ const STATE = {
|
|||||||
dashboardInterval: null,
|
dashboardInterval: null,
|
||||||
statusInterval: null,
|
statusInterval: null,
|
||||||
dbInterval: null,
|
dbInterval: null,
|
||||||
|
// 仪表盘增量刷新 (Bug 7)
|
||||||
|
dashboardRenderCount: 0,
|
||||||
|
// 资源使用 60s 滑动窗口历史 (Bug 6)
|
||||||
|
resourceHistory: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== WebSocket ==========
|
// ========== WebSocket ==========
|
||||||
@@ -543,6 +547,26 @@ function escHtml(s) {
|
|||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
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) {
|
function formatUptime(ms) {
|
||||||
if (!ms || ms < 0) return '—';
|
if (!ms || ms < 0) return '—';
|
||||||
const s = Math.floor(ms / 1000);
|
const s = Math.floor(ms / 1000);
|
||||||
@@ -701,7 +725,8 @@ function stopDbAutoRefresh() {
|
|||||||
async function renderDashboard() {
|
async function renderDashboard() {
|
||||||
const data = await api('/api/dashboard');
|
const data = await api('/api/dashboard');
|
||||||
if (data.error) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
STATE.dashboardData = data;
|
STATE.dashboardData = data;
|
||||||
@@ -709,106 +734,167 @@ async function renderDashboard() {
|
|||||||
const svcs = data.services?.list || {};
|
const svcs = data.services?.list || {};
|
||||||
const runningCount = data.services?.running || 0;
|
const runningCount = data.services?.running || 0;
|
||||||
const totalSvcs = data.services?.total || Object.keys(svcs).length;
|
const totalSvcs = data.services?.total || Object.keys(svcs).length;
|
||||||
|
const isFirstRender = STATE.dashboardRenderCount === 0;
|
||||||
|
|
||||||
document.getElementById('panel-dashboard').innerHTML = `
|
// Bug 7: 首次渲染完整 DOM,后续只做增量更新
|
||||||
<!-- 概览统计 -->
|
if (isFirstRender) {
|
||||||
<div class="cards-grid cards-4" style="margin-bottom:16px">
|
document.getElementById('panel-dashboard').innerHTML =
|
||||||
<div class="stat-card green">
|
'<!-- 概览统计 -->' +
|
||||||
<div class="stat-value">${runningCount}/${totalSvcs}</div>
|
'<div class="cards-grid cards-4" style="margin-bottom:16px">' +
|
||||||
<div class="stat-label">服务运行中</div>
|
'<div class="stat-card green">' +
|
||||||
</div>
|
'<div class="stat-value" id="stat-running">' + runningCount + '/' + totalSvcs + '</div>' +
|
||||||
<div class="stat-card blue">
|
'<div class="stat-label">服务运行中</div>' +
|
||||||
<div class="stat-value">${data.sessions?.active ?? '—'}</div>
|
'</div>' +
|
||||||
<div class="stat-label">活跃会话</div>
|
'<div class="stat-card blue">' +
|
||||||
</div>
|
'<div class="stat-value" id="stat-sessions">' + (data.sessions?.active ?? '—') + '</div>' +
|
||||||
<div class="stat-card accent">
|
'<div class="stat-label">活跃会话</div>' +
|
||||||
<div class="stat-value">${data.memory?.total ?? '—'}</div>
|
'</div>' +
|
||||||
<div class="stat-label">记忆条目</div>
|
'<div class="stat-card accent">' +
|
||||||
</div>
|
'<div class="stat-value" id="stat-memory">' + (data.memory?.total ?? '—') + '</div>' +
|
||||||
<div class="stat-card orange">
|
'<div class="stat-label">记忆条目</div>' +
|
||||||
<div class="stat-value">${data.system?.heapUsedMB ?? '—'} MB</div>
|
'</div>' +
|
||||||
<div class="stat-label">DevTools 内存</div>
|
'<div class="stat-card orange">' +
|
||||||
</div>
|
'<div class="stat-value" id="stat-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB</div>' +
|
||||||
</div>
|
'<div class="stat-label">DevTools 内存</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
<!-- 服务状态卡片 -->
|
'<!-- 服务状态卡片 -->' +
|
||||||
<div class="card">
|
'<div class="card">' +
|
||||||
<div class="card-header">
|
'<div class="card-header">' +
|
||||||
<span class="card-title">📡 服务状态</span>
|
'<span class="card-title">📡 服务状态</span>' +
|
||||||
<div class="quick-actions">
|
'<div class="quick-actions">' +
|
||||||
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
|
'<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" onclick="svcAction(\'start-all-fresh\')">🔄 强制重启全部</button>' +
|
||||||
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
|
'<button class="btn btn-sm btn-red" onclick="svcAction(\'stop-all\')">⏹ 全部停止</button>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>
|
'<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
|
|
||||||
<!-- 数据库状态卡片 -->
|
'<!-- 数据库状态卡片 -->' +
|
||||||
<div class="card" id="db-card">
|
'<div class="card" id="db-card">' +
|
||||||
<div class="card-header">
|
'<div class="card-header">' +
|
||||||
<span class="card-title">🗄️ 数据库</span>
|
'<span class="card-title">🗄️ 数据库</span>' +
|
||||||
<span class="badge badge-stopped" id="db-status-badge">检查中...</span>
|
'<span class="badge badge-stopped" id="db-status-badge">检查中...</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="metrics">
|
'<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-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-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 class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="btn-group" style="margin-top:10px">
|
'<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-green" onclick="controlDB(\'start\')">▶ 启动</button>' +
|
||||||
<button class="btn btn-xs btn-red" onclick="controlDB('stop')">⏹ 停止</button>
|
'<button class="btn btn-xs btn-red" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
|
||||||
<button class="btn btn-xs" onclick="controlDB('restart')">🔄 重启</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>
|
'<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>
|
'</div>' +
|
||||||
|
|
||||||
<!-- 性能快照 + 性能仪表盘 -->
|
'<!-- 性能快照 + 性能仪表盘 -->' +
|
||||||
<div class="cards-grid cards-2">
|
'<div class="cards-grid cards-2">' +
|
||||||
<div class="card">
|
'<div class="card">' +
|
||||||
<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>
|
'<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>' +
|
||||||
<div id="dashboard-perf"></div>
|
'<div id="dashboard-perf"></div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="card">
|
'<div class="card">' +
|
||||||
<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>
|
'<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>' +
|
||||||
<div id="performance-dashboard">
|
'<div id="performance-dashboard">' +
|
||||||
<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>
|
'<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
|
|
||||||
<!-- 系统信息 -->
|
'<!-- 系统信息 -->' +
|
||||||
<div class="card">
|
'<div class="card">' +
|
||||||
<div class="card-header"><span class="card-title">💻 系统信息</span></div>
|
'<div class="card-header"><span class="card-title">💻 系统信息</span></div>' +
|
||||||
<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap">
|
'<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap" id="sys-info-row">' +
|
||||||
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
|
'<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> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</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> ${data.sessions?.totalMessages ?? 0}</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> ${formatTime(data.timestamp)}</div>
|
'<div><span style="color:var(--text2)">更新时间:</span> <span id="sys-time">' + formatTime(data.timestamp) + '</span></div>' +
|
||||||
</div>
|
'</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);
|
renderDashboardSvcCards(svcs);
|
||||||
|
|
||||||
// 渲染数据库卡片
|
// 渲染数据库卡片 (renderDBCard 本身就只更新 textContent,见 Bug 7 fix)
|
||||||
renderDBCard();
|
renderDBCard();
|
||||||
|
|
||||||
// 渲染性能快照
|
// Bug 6: 渲染资源使用卡片 (增量更新 + sparkline)
|
||||||
const perfContainer = document.getElementById('dashboard-perf');
|
renderResourceUsage(data.performance?.perService || {});
|
||||||
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>';
|
|
||||||
|
|
||||||
// 渲染性能仪表盘
|
// 渲染性能仪表盘 (updatePerformanceDashboard 内联更新)
|
||||||
updatePerformanceDashboard(data.performance?.perService || {});
|
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 { /* 忽略: 使用本地计算的数据 */ }
|
} catch { /* 忽略: 使用本地计算的数据 */ }
|
||||||
|
|
||||||
container.innerHTML = `
|
// Bug 7: 增量更新 — 首次创建 DOM 结构,后续只更新数值
|
||||||
<div class="perf-dashboard">
|
const isFirstRender = !container.querySelector('.perf-dashboard');
|
||||||
<!-- CPU 使用率 -->
|
if (isFirstRender) {
|
||||||
<div class="perf-row">
|
container.innerHTML =
|
||||||
<span class="perf-label">🖥 CPU</span>
|
'<div class="perf-dashboard">' +
|
||||||
<div class="perf-bar-wrap">
|
'<!-- CPU 使用率 -->' +
|
||||||
<div class="perf-bar ${cpuLevel}" style="width:${Math.min(avgCpu, 100)}%"></div>
|
'<div class="perf-row">' +
|
||||||
</div>
|
'<span class="perf-label">🖥 CPU</span>' +
|
||||||
<span class="perf-value">${avgCpu}% ${trendCpu}</span>
|
'<div class="perf-bar-wrap">' +
|
||||||
</div>
|
'<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">
|
'<div class="perf-row">' +
|
||||||
<span class="perf-label">💾 内存</span>
|
'<span class="perf-label">💾 内存</span>' +
|
||||||
<div class="perf-bar-wrap">
|
'<div class="perf-bar-wrap">' +
|
||||||
<div class="perf-bar ${memLevel}" style="width:${Math.min(totalMem / 1024 * 100, 100)}%"></div>
|
'<div class="perf-bar ' + memLevel + '" id="perf-mem-bar" style="width:' + Math.min(totalMem / 1024 * 100, 100) + '%"></div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<span class="perf-value">${Math.round(totalMem)} MB ${trendMem}</span>
|
'<span class="perf-value" id="perf-mem-val">' + Math.round(totalMem) + ' MB ' + trendMem + '</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
|
|
||||||
<!-- 详细统计 -->
|
'<!-- 详细统计 -->' +
|
||||||
<div style="margin-top:8px">
|
'<div style="margin-top:8px">' +
|
||||||
<div class="perf-stat">
|
'<div class="perf-stat">' +
|
||||||
<span class="perf-stat-icon">⏱</span>
|
'<span class="perf-stat-icon">⏱</span>' +
|
||||||
<span class="perf-stat-label">平均请求延迟</span>
|
'<span class="perf-stat-label">平均请求延迟</span>' +
|
||||||
<span class="perf-stat-value" style="color:var(--yellow)">${avgLatency}</span>
|
'<span class="perf-stat-value" id="perf-latency" style="color:var(--yellow)">' + avgLatency + '</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="perf-stat">
|
'<div class="perf-stat">' +
|
||||||
<span class="perf-stat-icon">🔗</span>
|
'<span class="perf-stat-icon">🔗</span>' +
|
||||||
<span class="perf-stat-label">活跃连接数</span>
|
'<span class="perf-stat-label">活跃连接数</span>' +
|
||||||
<span class="perf-stat-value" style="color:var(--accent)">${activeCount}</span>
|
'<span class="perf-stat-value" id="perf-conns" style="color:var(--accent)">' + activeCount + '</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="perf-stat">
|
'<div class="perf-stat">' +
|
||||||
<span class="perf-stat-icon">📦</span>
|
'<span class="perf-stat-icon">📦</span>' +
|
||||||
<span class="perf-stat-label">监控服务数</span>
|
'<span class="perf-stat-label">监控服务数</span>' +
|
||||||
<span class="perf-stat-value" style="color:var(--blue)">${entries.length}</span>
|
'<span class="perf-stat-value" id="perf-svcs" style="color:var(--blue)">' + entries.length + '</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</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;
|
||||||
|
|
||||||
<!-- 各服务简要 -->
|
var memBar = document.getElementById('perf-mem-bar');
|
||||||
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px">
|
if (memBar) {
|
||||||
${entries.map(([id, p]) => `
|
memBar.className = 'perf-bar ' + memLevel;
|
||||||
<div class="perf-stat">
|
memBar.style.width = Math.min(totalMem / 1024 * 100, 100) + '%';
|
||||||
<span class="perf-stat-icon" style="font-size:12px">${p.pid ? '🟢' : '🔴'}</span>
|
}
|
||||||
<span class="perf-stat-label">${escapeId(id)}</span>
|
var memVal = document.getElementById('perf-mem-val');
|
||||||
<span class="perf-stat-value" style="font-size:11px;color:var(--text2)">
|
if (memVal) memVal.textContent = Math.round(totalMem) + ' MB ' + trendMem;
|
||||||
CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB
|
|
||||||
</span>
|
var latEl = document.getElementById('perf-latency');
|
||||||
</div>
|
if (latEl) latEl.textContent = avgLatency;
|
||||||
`).join('')}
|
var connEl = document.getElementById('perf-conns');
|
||||||
</div>
|
if (connEl) connEl.textContent = activeCount;
|
||||||
</div>
|
var svcEl = document.getElementById('perf-svcs');
|
||||||
`;
|
if (svcEl) svcEl.textContent = entries.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardSvcCards(svcs) {
|
function renderDashboardSvcCards(svcs) {
|
||||||
@@ -1192,17 +1292,30 @@ async function fetchActiveSessions() {
|
|||||||
|
|
||||||
if (Object.keys(users).length === 0) {
|
if (Object.keys(users).length === 0) {
|
||||||
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
|
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
|
||||||
|
STATE.expandedSessions = [];
|
||||||
return;
|
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 html = '';
|
||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
|
const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开
|
||||||
for (const [userID, sessions] of Object.entries(users)) {
|
for (const [userID, sessions] of Object.entries(users)) {
|
||||||
html += `<div style="margin-bottom:16px">`;
|
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>`;
|
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) {
|
for (const s of sessions) {
|
||||||
const idx = globalIndex++;
|
const idx = globalIndex++;
|
||||||
|
flatSessionMap.push({ index: idx, session: s, userID: userID });
|
||||||
html += `
|
html += `
|
||||||
<div style="padding:6px 0 6px 20px">
|
<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})">
|
<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;
|
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 兼容其他调用
|
// 保留旧 loadSessions 兼容其他调用
|
||||||
@@ -1231,9 +1442,9 @@ async function loadSessions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSessionDetail(index) {
|
async function toggleSessionDetail(index) {
|
||||||
const detailRow = document.getElementById(`session-detail-${index}`);
|
const detailRow = document.getElementById('session-detail-' + index);
|
||||||
const arrow = document.getElementById(`session-arrow-${index}`);
|
const arrow = document.getElementById('session-arrow-' + index);
|
||||||
const contentEl = document.getElementById(`session-detail-content-${index}`);
|
const contentEl = document.getElementById('session-detail-content-' + index);
|
||||||
|
|
||||||
if (detailRow.style.display !== 'none') {
|
if (detailRow.style.display !== 'none') {
|
||||||
// 折叠
|
// 折叠
|
||||||
@@ -1249,61 +1460,8 @@ async function toggleSessionDetail(index) {
|
|||||||
const session = STATE.sessionsData[index];
|
const session = STATE.sessionsData[index];
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
// 获取详情
|
// 委托给共用函数
|
||||||
const data = await api(`/api/sessions/${session.session_id}`);
|
await loadSessionDetailContent(session, contentEl);
|
||||||
|
|
||||||
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>'}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 面板4: 服务管理 ==========
|
// ========== 面板4: 服务管理 ==========
|
||||||
@@ -1529,21 +1687,36 @@ function renderPerfPanels(snap) {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
|
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
|
||||||
|
|
||||||
container.innerHTML = ids.map(id => {
|
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
|
||||||
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
const isFirstRender = !container.querySelector('.perf-card');
|
||||||
return `
|
|
||||||
<div class="card" style="margin:0">
|
if (isFirstRender) {
|
||||||
<div class="card-header">
|
container.innerHTML = ids.map(function(id) {
|
||||||
<span class="card-title">${escapeId(id)}</span>
|
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
||||||
<span style="font-size:11px;color:var(--text2)">CPU ${s.cpu}% | MEM ${s.mem}MB</span>
|
return '<div class="card perf-card" style="margin:0" data-svc="' + id + '">' +
|
||||||
</div>
|
'<div class="card-header">' +
|
||||||
<div class="chart-container">
|
'<span class="card-title">' + escapeId(id) + '</span>' +
|
||||||
<svg viewBox="0 0 300 120" class="chart-svg">
|
'<span style="font-size:11px;color:var(--text2)" class="perf-snap-val">CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB</span>' +
|
||||||
${drawChart(STATE.perfHistory[id] || [])}
|
'</div>' +
|
||||||
</svg>
|
'<div class="chart-container">' +
|
||||||
</div>
|
'<svg viewBox="0 0 300 120" class="chart-svg" id="perf-chart-' + id + '">' +
|
||||||
</div>`;
|
drawChart(STATE.perfHistory[id] || []) +
|
||||||
}).join('');
|
'</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) {
|
function drawChart(history) {
|
||||||
@@ -1582,14 +1755,14 @@ async function fetchDatabaseStatus() {
|
|||||||
async function renderDatabasePanel() {
|
async function renderDatabasePanel() {
|
||||||
const data = await fetchDatabaseStatus();
|
const data = await fetchDatabaseStatus();
|
||||||
|
|
||||||
document.getElementById('panel-actions').innerHTML = `
|
document.getElementById('panel-actions').innerHTML =
|
||||||
<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>
|
'<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>' +
|
||||||
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
|
'<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>';
|
||||||
`;
|
|
||||||
|
|
||||||
const panel = document.getElementById('panel-database');
|
const panel = document.getElementById('panel-database');
|
||||||
if (data.error) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1614,71 +1787,132 @@ async function renderDatabasePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.innerHTML = `
|
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素
|
||||||
<!-- 概览 -->
|
const isFirstRender = !STATE.dbInitialized;
|
||||||
<div class="card">
|
if (isFirstRender) {
|
||||||
<div class="card-header">
|
panel.innerHTML =
|
||||||
<span class="card-title">🔌 SSH 隧道状态</span>
|
'<!-- 概览 -->' +
|
||||||
<span class="badge ${tunnelRunning ? 'badge-running' : 'badge-stopped'}">${tunnelRunning ? '运行中' : '未运行'}</span>
|
'<div class="card">' +
|
||||||
</div>
|
'<div class="card-header">' +
|
||||||
<div class="db-summary">
|
'<span class="card-title">🔌 SSH 隧道状态</span>' +
|
||||||
<div class="db-summary-stat">
|
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</span>' +
|
||||||
<div class="val" style="color:${allAlive ? 'var(--green)' : 'var(--red)'}">${aliveCount}/${totalPorts}</div>
|
'</div>' +
|
||||||
<div class="lbl">数据库端口通联</div>
|
'<div class="db-summary">' +
|
||||||
</div>
|
'<div class="db-summary-stat">' +
|
||||||
${pg ? `
|
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
|
||||||
<div class="db-summary-stat">
|
'<div class="lbl">数据库端口通联</div>' +
|
||||||
<div class="val" style="color:var(--blue)">${pg.memories ?? '—'}</div>
|
'</div>' +
|
||||||
<div class="lbl">记忆条目 (${escHtml(pg.database || '')})</div>
|
(pg ?
|
||||||
</div>
|
'<div class="db-summary-stat">' +
|
||||||
` : ''}
|
'<div class="val" id="db-mem-count" style="color:var(--blue)">' + (pg.memories ?? '—') + '</div>' +
|
||||||
<div class="db-summary-stat">
|
'<div class="lbl">记忆条目 (' + escHtml(pg.database || '') + ')</div>' +
|
||||||
<div class="val" style="color:var(--text2)">${formatTime(data.timestamp)}</div>
|
'</div>'
|
||||||
<div class="lbl">最后检查时间</div>
|
: '<div class="db-summary-stat" style="display:none" id="db-mem-stat">' +
|
||||||
</div>
|
'<div class="val" id="db-mem-count" style="color:var(--blue)">—</div>' +
|
||||||
</div>
|
'<div class="lbl">记忆条目</div>' +
|
||||||
<div class="db-grid">
|
'</div>') +
|
||||||
${ports.map(p => `
|
'<div class="db-summary-stat">' +
|
||||||
<div class="db-port-card ${p.alive ? 'alive' : 'dead'}">
|
'<div class="val" id="db-check-time" style="color:var(--text2)">' + formatTime(data.timestamp) + '</div>' +
|
||||||
<div class="db-dot"></div>
|
'<div class="lbl">最后检查时间</div>' +
|
||||||
<div class="db-info">
|
'</div>' +
|
||||||
<div class="db-name">${escHtml(p.name)}</div>
|
'</div>' +
|
||||||
<div class="db-port-label">:${p.port} ${p.alive ? '✅' : '❌'}</div>
|
'<div class="db-grid" id="db-ports-grid">' +
|
||||||
</div>
|
ports.map(function(p) {
|
||||||
</div>
|
return '<div class="db-port-card ' + (p.alive ? 'alive' : 'dead') + '" data-port="' + p.port + '">' +
|
||||||
`).join('')}
|
'<div class="db-dot"></div>' +
|
||||||
</div>
|
'<div class="db-info">' +
|
||||||
</div>
|
'<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">' +
|
||||||
<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>
|
'<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>' +
|
||||||
<div class="btn-group" style="margin-bottom:8px">
|
'<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-green btn-sm" id="db-tunnel-start" onclick="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
|
||||||
<button class="btn btn-red btn-sm" onclick="tunnelAction('stop')" ${!tunnelRunning ? '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(\'restart\')">🔄 重启隧道</button>' +
|
||||||
<button class="btn btn-sm" onclick="tunnelAction('status')">📋 查看状态</button>
|
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
|
||||||
</div>
|
'</div>' +
|
||||||
${tunnelRunning && !allAlive ? '<div style="font-size:11px;color:var(--yellow);margin-bottom:8px">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</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 id="tunnel-log-container" style="display:none">' +
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
||||||
<span style="font-size:11px;color:var(--text2)">操作日志</span>
|
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
|
||||||
<button class="btn btn-xs" onclick="document.getElementById('tunnel-log-container').style.display='none'">✕</button>
|
'<button class="btn btn-xs" onclick="document.getElementById(\'tunnel-log-container\').style.display=\'none\'">✕</button>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="tunnel-log" id="tunnel-log"></div>
|
'<div class="tunnel-log" id="tunnel-log"></div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</div>
|
'</div>' +
|
||||||
|
|
||||||
<!-- 数据库连接信息 -->
|
'<!-- 数据库连接信息 -->' +
|
||||||
<div class="card">
|
'<div class="card">' +
|
||||||
<div class="card-header"><span class="card-title">📋 连接说明</span></div>
|
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
|
||||||
<div style="font-size:12px;color:var(--text2);line-height:1.8">
|
'<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>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
|
||||||
<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</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>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
|
||||||
</div>
|
'</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() {
|
function refreshDatabasePanel() {
|
||||||
|
|||||||
@@ -35,19 +35,21 @@ var IOT_COLOR_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function renderIoTPanel() {
|
async function renderIoTPanel() {
|
||||||
|
var result = await fetchIoTDevices();
|
||||||
|
var panel = document.getElementById('panel-iot');
|
||||||
|
|
||||||
|
// 更新操作栏 (只更新时间戳文本)
|
||||||
document.getElementById('panel-actions').innerHTML =
|
document.getElementById('panel-actions').innerHTML =
|
||||||
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
|
'<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>';
|
'<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) {
|
if (result.error) {
|
||||||
var hint = '';
|
var hint = '';
|
||||||
if (result.error.errorType === 'iot_not_running') {
|
if (result.error.errorType === 'iot_not_running') {
|
||||||
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
|
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>';
|
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
|
||||||
|
STATE.iotInitialized = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,14 +70,133 @@ async function renderIoTPanel() {
|
|||||||
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '<div class="iot-refresh-bar">' +
|
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
|
||||||
'<span style="font-weight:600;font-size:14px">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
var grid = document.getElementById('iot-device-grid');
|
||||||
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
var firstRender = !STATE.iotInitialized;
|
||||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
|
||||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
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) {
|
function renderIoTDeviceCard(device) {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 7.0 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -64,6 +64,7 @@ export default function App() {
|
|||||||
const found = currentSessions.find((s) => s.id === hashId);
|
const found = currentSessions.find((s) => s.id === hashId);
|
||||||
if (found) {
|
if (found) {
|
||||||
setCurrentSessionId(found.id);
|
setCurrentSessionId(found.id);
|
||||||
|
setHashSessionId(found.id);
|
||||||
await loadMessagesFromServer(found.id);
|
await loadMessagesFromServer(found.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,7 @@ export default function App() {
|
|||||||
if (resp.messages && resp.messages.length > 0) {
|
if (resp.messages && resp.messages.length > 0) {
|
||||||
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
|
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
|
||||||
setCurrentSessionId(hashId);
|
setCurrentSessionId(hashId);
|
||||||
|
setHashSessionId(hashId);
|
||||||
const msgs = resp.messages.map((m: any, i: number) => ({
|
const msgs = resp.messages.map((m: any, i: number) => ({
|
||||||
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
|
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ChatContainer() {
|
|||||||
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
|
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
|
||||||
|
|
||||||
return (
|
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 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">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
@@ -120,12 +121,35 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户头像占位 */}
|
{/* 用户头像 */}
|
||||||
{isUser && (
|
{isUser && <UserAvatar />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 { usePersonaStore } from '@/store/personaStore';
|
||||||
import type { CyreneForm } from '@/types/persona';
|
import type { CyreneForm, Mood } from '@/types/persona';
|
||||||
|
|
||||||
interface CyreneAvatarProps {
|
interface CyreneAvatarProps {
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
className?: string;
|
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: '🌸',
|
mimi: '🌸',
|
||||||
default: '🌺',
|
default: '🌺',
|
||||||
de_moi_ge: '🌌',
|
de_moi_ge: '🌌',
|
||||||
@@ -18,18 +45,35 @@ const SIZE_CLASS = {
|
|||||||
lg: 'w-20 h-20 text-4xl',
|
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) {
|
export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) {
|
||||||
const { currentForm } = usePersonaStore();
|
const { currentForm, mood } = usePersonaStore();
|
||||||
const emoji = FORM_AVATAR[currentForm] || '🌸';
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const avatarSrc = getAvatarPath(currentForm, mood);
|
||||||
|
const fallbackEmoji = FORM_FALLBACK_EMOJI[currentForm] || '🌸';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}`}
|
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={`昔涟 · ${currentForm === 'mimi' ? '迷迷' : currentForm === 'de_moi_ge' ? '德谬歌' : '小昔涟'}`}
|
title={`昔涟 · ${FORM_LABEL[currentForm]}`}
|
||||||
>
|
>
|
||||||
<span role="img" aria-label="昔涟">
|
{imgError ? (
|
||||||
{emoji}
|
<span role="img" aria-label="昔涟">
|
||||||
</span>
|
{fallbackEmoji}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={`昔涟 · ${FORM_LABEL[currentForm]}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
|
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
|
||||||
import { useChatStore } from '@/store/chatStore';
|
import { useChatStore } from '@/store/chatStore';
|
||||||
import { createSession as apiCreateSession } from '@/api/sessions';
|
import { createSession as apiCreateSession } from '@/api/sessions';
|
||||||
import type { Session } from '@/types/session';
|
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 */
|
/** 生成简易随机ID */
|
||||||
function randomID(n: number = 12): string {
|
function randomID(n: number = 12): string {
|
||||||
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
@@ -73,6 +83,7 @@ export function useSession() {
|
|||||||
};
|
};
|
||||||
addSession(newSession);
|
addSession(newSession);
|
||||||
setCurrentSessionId(newSession.id);
|
setCurrentSessionId(newSession.id);
|
||||||
|
setHashSessionId(newSession.id);
|
||||||
return newSession;
|
return newSession;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -111,6 +122,7 @@ export function useSession() {
|
|||||||
const setCurrentSession = useCallback(
|
const setCurrentSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
setCurrentSessionId(id);
|
setCurrentSessionId(id);
|
||||||
|
setHashSessionId(id);
|
||||||
// 加载该会话的消息历史
|
// 加载该会话的消息历史
|
||||||
await loadMessagesFromServer(id);
|
await loadMessagesFromServer(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
|||||||
import { useChatStore } from '@/store/chatStore';
|
import { useChatStore } from '@/store/chatStore';
|
||||||
import { useSessionStore } from '@/store/sessionStore';
|
import { useSessionStore } from '@/store/sessionStore';
|
||||||
import { getToken } from '@/api/client';
|
import { getToken } from '@/api/client';
|
||||||
import { fetchMessages } from '@/api/sessions';
|
|
||||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||||
|
|
||||||
const WS_BASE_URL =
|
const WS_BASE_URL =
|
||||||
@@ -14,7 +13,6 @@ export function useWebSocket() {
|
|||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const shouldReconnectRef = useRef(true);
|
const shouldReconnectRef = useRef(true);
|
||||||
const activeSessionRef = useRef<string | null>(null);
|
const activeSessionRef = useRef<string | null>(null);
|
||||||
const loadingRef = useRef(false); // 防止重复加载消息
|
|
||||||
|
|
||||||
// 订阅 sessionStore 中的 currentSessionId 变化
|
// 订阅 sessionStore 中的 currentSessionId 变化
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
@@ -83,38 +81,11 @@ export function useWebSocket() {
|
|||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 会话切换时:先通过 REST API 加载历史消息,再建立 WS 连接
|
// 会话切换时:重建 WebSocket 连接(消息历史由 useSession.setCurrentSession 负责加载)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeSessionRef.current = currentSessionId;
|
activeSessionRef.current = currentSessionId;
|
||||||
|
|
||||||
const loadAndConnect = async () => {
|
connect();
|
||||||
// 如果是从 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();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (reconnectTimerRef.current) {
|
if (reconnectTimerRef.current) {
|
||||||
|
|||||||
@@ -103,6 +103,21 @@
|
|||||||
margin-left: 2px;
|
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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
messages: state.currentSessionId === id ? [] : state.messages,
|
messages: state.currentSessionId === id ? [] : state.messages,
|
||||||
})),
|
})),
|
||||||
setCurrentSessionId: (id) => {
|
setCurrentSessionId: (id) => {
|
||||||
|
const oldId = get().currentSessionId;
|
||||||
set({ currentSessionId: id });
|
set({ currentSessionId: id });
|
||||||
// 切换会话时清空旧消息,等待加载
|
// 切换会话时清空旧消息,等待加载
|
||||||
if (id !== get().currentSessionId) {
|
if (id !== oldId) {
|
||||||
set({ messages: [], loading: true });
|
set({ messages: [], loading: true });
|
||||||
useChatStore.getState().clearMessages();
|
useChatStore.getState().clearMessages();
|
||||||
}
|
}
|
||||||
@@ -141,22 +142,31 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
const remaining = state.sessions.filter((s) => s.id !== id);
|
|
||||||
const wasCurrent = state.currentSessionId === id;
|
const wasCurrent = state.currentSessionId === id;
|
||||||
|
|
||||||
// 更新本地列表
|
// 从服务端重新加载会话列表,保证侧边栏同步
|
||||||
set({ sessions: remaining });
|
await get().loadSessionsFromServer(userId);
|
||||||
|
const refreshed = get().sessions;
|
||||||
|
|
||||||
if (wasCurrent) {
|
if (wasCurrent) {
|
||||||
if (remaining.length > 0) {
|
if (refreshed.length > 0) {
|
||||||
// 切换到列表中的第一个会话
|
// 切换到列表中的第一个会话
|
||||||
const nextId = remaining[0].id;
|
const nextId = refreshed[0].id;
|
||||||
set({ currentSessionId: nextId });
|
set({ currentSessionId: nextId });
|
||||||
await get().loadMessagesFromServer(nextId);
|
await get().loadMessagesFromServer(nextId);
|
||||||
} else {
|
} else {
|
||||||
// 没有会话了:管理员回到主对话,普通用户创建新对话
|
// 没有会话了:管理员回到主对话,普通用户创建新对话
|
||||||
set({ currentSessionId: null, messages: [] });
|
set({ currentSessionId: null, messages: [] });
|
||||||
useChatStore.getState().clearMessages();
|
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);
|
const ok = await apiDeleteAllSessions(userId);
|
||||||
if (!ok) return;
|
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: [] });
|
set({ sessions: [], currentSessionId: null, messages: [] });
|
||||||
useChatStore.getState().clearMessages();
|
useChatStore.getState().clearMessages();
|
||||||
|
|
||||||
|
// 管理员:自动创建主对话
|
||||||
|
if (isAdminUser(userId)) {
|
||||||
|
const mainSession = await get().ensureMainSession(userId);
|
||||||
|
if (mainSession) {
|
||||||
|
set({ currentSessionId: mainSession.id });
|
||||||
|
useChatStore.getState().clearMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||