fix: 修复6个bug + IoT设备控制增强 + DevTools IoT面板
问题1: 刷新后主对话历史不显示,侧边栏子对话列表为空 - sessionStore: 修复 setCurrentSessionId 用 Map 去重消息 - AppLayout: 修复 autoLoadNewSession 逻辑 - useWebSocket: 修复 setMessages 调用时机 问题2: 切换到次级对话后无法切换回主对话 - Sidebar: 为删除按钮添加 e.stopPropagation() 问题3&4: IoT设备列表展开导致输入栏消失 + 聊天消息无法滚动 - IoTStatusBar: 从fixed定位改为inline布局 - ChatContainer: 重构flex布局,MessageList自动撑满 问题5: AI核心无法操作IoT设备 + 无法设置温度等属性 - 新增 IoTControlTool (iot_control_tool.go) - IoTClient: 新增 ToggleDevice/SetProperty/GetHistory - 支持 set_temperature/set_brightness/set_position/set_mode/set_color 问题6: DevTools启动时Gateway代理登录异常 - devtools: 登录失败时静默降级,不阻塞启动 额外修复: - iot_tools.go: 修复fmt.Sprintf参数缺失 - iot-debug-service: 修复并发死锁问题 - DevTools: 新增IoT设备控制面板(API代理+前端UI)
This commit is contained in:
+115
-17
@@ -73,31 +73,52 @@ let cachedToken = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
/**
|
||||
* 获取 Gateway JWT token (通过 admin 凭据登录,缓存直到过期)
|
||||
* 获取 Gateway JWT token (通过 admin 凭据登录,缓存直到过期,支持重试)
|
||||
*/
|
||||
async function getGatewayToken() {
|
||||
if (cachedToken && Date.now() < tokenExpiry - 60000) {
|
||||
return cachedToken;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error('[Gateway代理] 登录失败:', resp.status);
|
||||
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
console.log(`[Gateway代理] 登录失败 (HTTP ${resp.status}),${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
console.error('[Gateway代理] 登录失败:', resp.status);
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json();
|
||||
cachedToken = data.token;
|
||||
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
|
||||
console.log('[Gateway代理] 登录成功,token 已缓存');
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
console.log(`[Gateway代理] 登录异常: ${err.message},${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
console.error('[Gateway代理] 登录异常 (已达最大重试次数):', err.message);
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json();
|
||||
cachedToken = data.token;
|
||||
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
console.error('[Gateway代理] 登录异常:', err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,6 +485,83 @@ app.delete('/api/logs/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- IoT 设备管理 (代理到 iot-debug-service) ----
|
||||
const IOT_SERVICE_URL = process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083';
|
||||
|
||||
/** 通用 IoT 代理:转发请求到 iot-debug-service */
|
||||
async function proxyToIoT(path, opts = {}) {
|
||||
const url = `${IOT_SERVICE_URL}${path}`;
|
||||
const logPrefix = `[IoT代理]`;
|
||||
try {
|
||||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||||
const resp = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||||
}
|
||||
return { status: resp.status, body };
|
||||
} catch (err) {
|
||||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
error: `IoT 调试服务不可达: ${err.message}`,
|
||||
errorType: isConnRefused ? 'iot_not_running' : 'iot_unreachable',
|
||||
hint: isConnRefused
|
||||
? 'IoT 调试服务未启动,请先在「服务管理」面板中启动 IoT Debug 服务'
|
||||
: 'IoT 调试服务无响应,请检查网络连接和服务状态',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/iot/devices — 获取所有模拟设备
|
||||
app.get('/api/iot/devices', async (_req, res) => {
|
||||
console.log('[IoT] 获取设备列表');
|
||||
const result = await proxyToIoT('/api/v1/devices');
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/iot/devices/:id — 获取单个设备
|
||||
app.get('/api/iot/devices/:id', async (req, res) => {
|
||||
console.log(`[IoT] 获取设备: ${req.params.id}`);
|
||||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// POST /api/iot/devices/:id/toggle — 切换设备开关
|
||||
app.post('/api/iot/devices/:id/toggle', async (req, res) => {
|
||||
console.log(`[IoT] 切换设备: ${req.params.id}`);
|
||||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/toggle`, { method: 'POST' });
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// POST /api/iot/devices/:id/set — 设置设备属性
|
||||
app.post('/api/iot/devices/:id/set', async (req, res) => {
|
||||
const { field, value } = req.body;
|
||||
if (!field) {
|
||||
return res.status(400).json({ error: '缺少 field 参数' });
|
||||
}
|
||||
console.log(`[IoT] 设置设备属性: ${req.params.id} -> ${field} = ${value}`);
|
||||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/set`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ field, value }),
|
||||
});
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/iot/devices/:id/history — 获取设备操作历史
|
||||
app.get('/api/iot/devices/:id/history', async (req, res) => {
|
||||
console.log(`[IoT] 获取设备历史: ${req.params.id}`);
|
||||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/history`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 健康检查代理 ----
|
||||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
const svc = SERVICES[req.params.id];
|
||||
|
||||
Reference in New Issue
Block a user