import { useCallback, useEffect, useRef, useState } from 'react'; import type { ComponentType } from 'react'; import { Card, Col, Row, Tag, Button, Space, message, Spin, Empty, InputNumber, Modal, Alert, Result, Popconfirm } from 'antd'; import { ApiOutlined, BulbOutlined, SendOutlined, StopOutlined, DeleteOutlined, InfoCircleOutlined, PoweroffOutlined, ThunderboltOutlined, CloudOutlined, FireOutlined, } from '@ant-design/icons'; import { get, post } from '../api/http'; interface Device { id: string; deviceKey?: string; name: string; onlineStatus?: string; } interface IRResult { hasResult: boolean; result?: { action: string; success: boolean; no: number; timestamp: string; }; learnedCodes?: number[]; } // 空调遥控按钮配置(顺序学习) const AIRCON_BUTTONS = [ { key: 'power-off', label: '关机', no: 1, icon: 'PoweroffOutlined' }, { key: 'power-on', label: '开机', no: 2, icon: 'PoweroffOutlined' }, { key: 'cooling', label: '制冷', no: 6, icon: 'CloudOutlined' }, { key: 'heating', label: '制热', no: 7, icon: 'FireOutlined' }, { key: 'dehumid', label: '除湿', no: 5, icon: 'ThunderboltOutlined' }, ]; // 温度按钮配置(单独学习) const TEMP_BUTTONS = [ { key: 'temp-23', label: '23度', no: 3 }, { key: 'temp-25', label: '25度', no: 4 }, { key: 'temp-27', label: '27度', no: 8 }, { key: 'temp-29', label: '29度', no: 9 }, ]; // 图标名 -> 图标组件映射 const ICON_MAP: Record = { PoweroffOutlined, ThunderboltOutlined, CloudOutlined, FireOutlined, }; // 顺序学习的编号顺序(与 AIRCON_BUTTONS 的 no 字段一致) const LEARN_SEQUENCE = [1, 2, 6, 7, 5]; // 所有可学习的按钮(用于查找按钮标签) const ALL_BUTTONS = [...AIRCON_BUTTONS, ...TEMP_BUTTONS]; // 闪烁动画样式(正在学习的按钮高亮闪烁) const blinkStyle = ` @keyframes ir-blink { 0%, 100% { background-color: #faad14; } 50% { background-color: #fffbe6; } } .ir-blinking { animation: ir-blink 0.8s infinite; } `; const IRControl = () => { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); const [sending, setSending] = useState(''); const [learnModal, setLearnModal] = useState<{ open: boolean; deviceId: string; no: number }>({ open: false, deviceId: '', no: 100 }); const [emitNo, setEmitNo] = useState>({}); const [irResults, setIrResults] = useState>({}); const [deviceInfo, setDeviceInfo] = useState>({}); // 空调顺序学习状态:记录当前正在学习的设备与编号 const [learningDevice, setLearningDevice] = useState<{ deviceId: string; currentNo: number } | null>(null); // 使用 ref 跟踪 learningDevice,避免轮询中的闭包陷阱 const learningDeviceRef = useRef<{ deviceId: string; currentNo: number } | null>(null); // 空调按钮发射 loading(key = deviceId + button.key) const [airconSending, setAirconSending] = useState(''); // 同步更新 state 与 ref const updateLearningDevice = (val: { deviceId: string; currentNo: number } | null) => { learningDeviceRef.current = val; setLearningDevice(val); }; const fetchDevices = useCallback(async () => { setLoading(true); try { const data = await get('/devices'); const all = Array.isArray(data) ? data : data.items ?? []; // 只显示红外控制器(名称包含"红外") const items = all.filter((d) => d.name.includes('红外')); setDevices(items); } catch { message.error('加载设备列表失败'); } setLoading(false); }, []); useEffect(() => { fetchDevices(); }, [fetchDevices]); const actionLabel = (action: string) => { const labels: Record = { learn: '学习', emit: '发射', learnCancel: '取消学习', erase: '擦除' }; return labels[action] || action; }; // 擦除结果轮询:erase 命令可能不返回响应,短时间轮询确认 const pollEraseResult = async (deviceId: string) => { const startTime = Date.now(); for (let i = 0; i < 5; i++) { await new Promise((r) => setTimeout(r, 2000)); try { const data = await get(`/devices/${deviceId}/ir/status`, { silent: true }); if (data.hasResult && data.result) { const resultTime = new Date(data.result.timestamp).getTime(); if (resultTime >= startTime && data.result.action === 'erase') { if (data.result.success) { message.success('擦除成功'); } else { message.warning('设备已进入擦除模式,本地列表已清空'); } return; } } } catch {} } // 超时:设备可能不返回 erase 响应,本地列表已清空 message.info('设备未响应擦除结果,本地红外码列表已清空'); }; // 轮询红外操作结果(原有功能:用于手动学习/发射/擦除) const pollIRResult = async (deviceId: string, action: string, no?: number) => { for (let i = 0; i < 15; i++) { await new Promise((r) => setTimeout(r, 2000)); try { const data = await get(`/devices/${deviceId}/ir/status`, { silent: true }); if (data.hasResult && data.result) { // 检查是否是本次操作的结果(action 匹配,且时间戳在最近 30 秒内) const resultTime = new Date(data.result.timestamp).getTime(); if (Date.now() - resultTime < 30000) { if (data.result.action === action && (no === undefined || data.result.no === no || data.result.no === 0)) { // success=false 表示设备已进入操作模式(如进入学习/擦除模式),不是真正失败,继续轮询 if (!data.result.success) { continue; } setIrResults((prev) => ({ ...prev, [deviceId]: data.result })); // 擦除成功后清空本地已学习红外码列表 if (action === 'erase') { setDeviceInfo((prev) => { const curInfo = prev?.[deviceId]; return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [] } }; }); } message.success(`${actionLabel(data.result.action)}成功${data.result.no ? `(编号 ${data.result.no})` : ''}`); return; } } } } catch {} } message.warning(`${actionLabel(action)}等待结果超时,请重试`); }; // 轮询设备信息(info 响应后 signal 会被入库) const pollDeviceInfo = async (device: Device) => { const key = device.deviceKey || device.id; for (let i = 0; i < 10; i++) { await new Promise((r) => setTimeout(r, 2000)); try { // 查询遥测数据(signal) const telemetry = await get<{ metric: string; value: number; timestamp: string }[]>( `/telemetry/${key}/latest`, { silent: true } ); // 查询已学习的红外码 const irStatus = await get<{ hasResult: boolean; learnedCodes?: number[] }>( `/devices/${device.id}/ir/status`, { silent: true } ); const signalRec = Array.isArray(telemetry) ? telemetry.find((r) => r.metric === 'signal') : undefined; if (signalRec || irStatus.learnedCodes) { setDeviceInfo((prev) => ({ ...prev, [device.id]: { signal: signalRec?.value, lastSeen: signalRec?.timestamp, learnedCodes: irStatus.learnedCodes || [], }, })); if (signalRec) { const codes = irStatus.learnedCodes || []; message.success(`设备信息已更新:信号 ${signalRec.value},已学习红外码 ${codes.length} 个`); } return; } } catch {} } message.warning('设备未响应,可能已离线'); }; // 原有通用指令发送(左侧按钮使用) const sendIR = async (device: Device, action: 'learn' | 'emit' | 'cancel' | 'erase' | 'info', no?: number) => { const key = device.id + action + (no ?? ''); setSending(key); // 清除之前的结果 setIrResults((prev) => ({ ...prev, [device.id]: undefined })); try { if (action === 'learn' && no !== undefined) { await post(`/devices/${device.id}/ir/learn`, { no }); message.success(`学习指令已发送(编号 ${no}),请将遥控器对准设备按键`); setLearnModal({ open: true, deviceId: device.id, no }); pollIRResult(device.id, 'learn', no); } else if (action === 'emit' && no !== undefined) { await post(`/devices/${device.id}/ir/emit`, { no }); message.success(`发射指令已发送(编号 ${no})`); pollIRResult(device.id, 'emit', no); } else if (action === 'cancel') { await post(`/devices/${device.id}/ir/cancel`); message.success('取消学习指令已发送'); setLearnModal({ ...learnModal, open: false }); // learnCancel 仅在学习模式下有响应,不轮询结果 } else if (action === 'erase') { await post(`/devices/${device.id}/ir/erase`); // 立即清空本地已学习红外码列表 setDeviceInfo((prev) => { const curInfo = prev?.[device.id]; return { ...prev, [device.id]: { ...curInfo, learnedCodes: [] } }; }); message.success('擦除指令已发送,已清空本地红外码列表'); // erase 可能像 learnCancel 一样不返回响应,短时间轮询确认 pollEraseResult(device.id); } else if (action === 'info') { await post(`/devices/${device.id}/plug/info`); message.success('查询信息指令已发送,等待设备响应...'); // 轮询遥测数据(info 响应中的 signal 会被入库) pollDeviceInfo(device); } } catch (err: any) { const errMsg = err?.response?.data?.error || '指令发送失败'; message.error(errMsg); } setSending(''); }; // 空调按钮发射(右侧空调面板使用) const emitAircon = async (deviceId: string, button: typeof AIRCON_BUTTONS[number]) => { // 正在顺序学习中,不触发发射 if (learningDeviceRef.current && learningDeviceRef.current.deviceId === deviceId) { return; } const key = deviceId + button.key; setAirconSending(key); try { await post(`/devices/${deviceId}/ir/emit`, { no: button.no }); message.success(`【${button.label}】指令已发送`); } catch (err: any) { const errMsg = err?.response?.data?.error || `${button.label}指令发送失败`; message.error(errMsg); } setAirconSending(''); }; // 顺序学习轮询:循环查询学习结果,成功后自动进入下一个编号 // startTime 用于过滤旧结果,只接受该时间之后产生的学习结果 const pollSequentialLearn = async (deviceId: string, no: number, startTime: number) => { for (let i = 0; i < 15; i++) { await new Promise((r) => setTimeout(r, 2000)); // 通过 ref 检查是否已被取消或已切换到下一个编号 const cur = learningDeviceRef.current; if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) { return; // 已被取消或已切换 } try { const data = await get(`/devices/${deviceId}/ir/status`, { silent: true }); if (data.hasResult && data.result) { const resultTime = new Date(data.result.timestamp).getTime(); // 只接受 startTime 之后的结果,避免读到上一个编号的旧结果 // 设备学习成功时返回 no=0,失败时返回发送的编号,因此匹配条件需兼容 no=0 if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) { const curButton = ALL_BUTTONS.find((b) => b.no === no); if (data.result.success) { // 学习成功,更新本地 learnedCodes setDeviceInfo((prev) => { const curInfo = prev?.[deviceId]; const codes = curInfo?.learnedCodes ?? []; if (!codes.includes(no)) codes.push(no); return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } }; }); // 查找当前编号在学习序列中的位置,决定下一个学习的编号 const seqIndex = LEARN_SEQUENCE.indexOf(no); const nextNo = seqIndex >= 0 && seqIndex + 1 < LEARN_SEQUENCE.length ? LEARN_SEQUENCE[seqIndex + 1] : null; if (nextNo !== null) { const nextStartTime = Date.now(); await post(`/devices/${deviceId}/ir/learn`, { no: nextNo }); updateLearningDevice({ deviceId, currentNo: nextNo }); const nextLabel = ALL_BUTTONS.find((b) => b.no === nextNo)?.label; message.success(`【${curButton?.label}】学习成功,请按下遥控器的【${nextLabel}】键`); pollSequentialLearn(deviceId, nextNo, nextStartTime); } else { // 全部完成 updateLearningDevice(null); message.success('全部学习完成'); } return; } // success=false 表示设备已进入学习模式,等待用户按遥控器,继续轮询 } } } catch {} } // 超时:仅当仍处于当前学习状态时才提示 const cur = learningDeviceRef.current; if (cur && cur.deviceId === deviceId && cur.currentNo === no) { updateLearningDevice(null); message.warning('学习超时,请重试'); } }; // 开始顺序学习:从学习序列的第一个编号开始 const startSequentialLearn = async (deviceId: string) => { try { const firstNo = LEARN_SEQUENCE[0]; const startTime = Date.now(); await post(`/devices/${deviceId}/ir/learn`, { no: firstNo }); updateLearningDevice({ deviceId, currentNo: firstNo }); const firstLabel = ALL_BUTTONS.find((b) => b.no === firstNo)?.label; message.success(`开始学习:请按下遥控器的【${firstLabel}】键`); pollSequentialLearn(deviceId, firstNo, startTime); } catch (err: any) { const errMsg = err?.response?.data?.error || '学习指令发送失败'; message.error(errMsg); } }; // 取消顺序学习 const cancelSequentialLearn = async (deviceId: string) => { try { await post(`/devices/${deviceId}/ir/cancel`, {}); } catch {} updateLearningDevice(null); message.success('已取消学习'); }; // 温度按钮单独学习 const learnSingleTemp = async (deviceId: string, no: number, label: string) => { // 如果正在顺序学习,不允许单独学习 if (learningDeviceRef.current !== null) { message.warning('正在顺序学习中,请先取消'); return; } try { const startTime = Date.now(); updateLearningDevice({ deviceId, currentNo: no }); await post(`/devices/${deviceId}/ir/learn`, { no }); message.success(`请按下遥控器的【${label}】键`); // 轮询单次学习结果 for (let i = 0; i < 15; i++) { await new Promise((r) => setTimeout(r, 2000)); const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null; if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) { return; // 已被取消 } try { const data = await get(`/devices/${deviceId}/ir/status`, { silent: true }); if (data.hasResult && data.result) { const resultTime = new Date(data.result.timestamp).getTime(); if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) { if (!data.result.success) { continue; // 进入学习模式,继续等 } // 学习成功 setDeviceInfo((prev) => { const curInfo = prev?.[deviceId]; const codes = curInfo?.learnedCodes ?? []; if (!codes.includes(no)) codes.push(no); return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } }; }); updateLearningDevice(null); message.success(`【${label}】学习成功`); return; } } } catch {} } // 超时 const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null; if (cur && cur.deviceId === deviceId && cur.currentNo === no) { updateLearningDevice(null); message.warning(`【${label}】学习超时,请重试`); } } catch (err: any) { updateLearningDevice(null); const errMsg = err?.response?.data?.error || '学习指令发送失败'; message.error(errMsg); } }; const isOnline = (d: Device) => d.onlineStatus === 'online'; if (!loading && devices.length === 0) { return ; } return ( {devices.map((d) => { const online = isOnline(d); const currentNo = emitNo[d.id] ?? 100; const irResult = irResults[d.id]; const info = deviceInfo[d.id]; const learnedCodes = info?.learnedCodes ?? []; // 当前设备是否处于顺序学习 const isLearning = learningDevice?.deviceId === d.id; const learningNo = isLearning ? learningDevice!.currentNo : null; return ( {d.name}} extra={{online ? '在线' : '离线'}} > {/* 左侧:原有通用功能 */} {/* 操作结果反馈 */} {irResult && (
)} {/* 设备信息 */} {info && (
设备信息
4G 信号:= 20 ? 'green' : (info.signal ?? 0) >= 10 ? 'orange' : 'red'}>{info.signal ?? '--'}/31
最后上报:{info.lastSeen ? new Date(info.lastSeen).toLocaleTimeString() : '--'}
已学习红外码: {info.learnedCodes && info.learnedCodes.length > 0 ? ( {info.learnedCodes.slice().sort((a, b) => a - b).map((no) => ( setEmitNo({ ...emitNo, [d.id]: no })}> {no} ))} ) : ( 暂无 )}
)} {/* 发射红外码 */}
发射红外码
setEmitNo({ ...emitNo, [d.id]: v ?? 100 })} style={{ width: 80 }} disabled={isLearning} />
{/* 学习红外码 */}
学习红外码
setLearnModal({ ...learnModal, no: v ?? 100 })} style={{ width: 80 }} disabled={isLearning} />
{/* 其他操作 */} sendIR(d, 'erase')} > {/* 右侧:空调遥控面板 */}
空调遥控面板 {isLearning && ( 学习中... )}
{/* 开始学习 / 取消学习 按钮 */}
{isLearning ? ( ) : ( )}
{/* 空调控制按钮(顺序学习) */} {AIRCON_BUTTONS.map((btn) => { const Icon = ICON_MAP[btn.icon]; const learned = learnedCodes.includes(btn.no); const isBlinking = isLearning && learningNo === btn.no; const isLoading = airconSending === d.id + btn.key; return ( ); })} {/* 温度按钮(单独学习) */}
温度按钮(点击未学习的按钮开始单独学习)
{TEMP_BUTTONS.map((btn) => { const learned = learnedCodes.includes(btn.no); const isBlinking = isLearning && learningNo === btn.no; return ( ); })} {isLearning && (
当前学习:【{ALL_BUTTONS.find((b) => b.no === learningNo)?.label}】,请将遥控器对准设备按键
)}
); })}
{/* 学习中提示弹窗(保留原有功能) */} { post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {}); setLearnModal({ ...learnModal, open: false }); }} footer={[ , , ]} >
); }; export default IRControl;