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,
|
||||
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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||