Files
silk/web/src/pages/IRControl.tsx
T

718 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, ComponentType> = {
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<Device[]>([]);
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<Record<string, number>>({});
const [irResults, setIrResults] = useState<Record<string, { action: string; success: boolean; no: number } | undefined>>({});
const [deviceInfo, setDeviceInfo] = useState<Record<string, { signal?: number; lastSeen?: string; learnedCodes?: number[] } | undefined>>({});
// 空调顺序学习状态:记录当前正在学习的设备与编号
const [learningDevice, setLearningDevice] = useState<{ deviceId: string; currentNo: number } | null>(null);
// 使用 ref 跟踪 learningDevice,避免轮询中的闭包陷阱
const learningDeviceRef = useRef<{ deviceId: string; currentNo: number } | null>(null);
// 空调按钮发射 loadingkey = deviceId + button.key
const [airconSending, setAirconSending] = useState<string>('');
// 同步更新 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<Device[] | { items: Device[] }>('/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<string, string> = { 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<IRResult>(`/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<IRResult>(`/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<IRResult>(`/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<IRResult>(`/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 <Empty description="暂无设备" />;
}
return (
<Spin spinning={loading}>
<style>{blinkStyle}</style>
<Row gutter={[16, 16]}>
{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 (
<Col key={d.id} span={24}>
<Card
title={<Space><ApiOutlined />{d.name}</Space>}
extra={<Tag color={online ? 'green' : 'default'}>{online ? '在线' : '离线'}</Tag>}
>
<Row gutter={[24, 0]}>
{/* 左侧:原有通用功能 */}
<Col span={12}>
{/* 操作结果反馈 */}
{irResult && (
<div style={{ marginBottom: 16 }}>
<Result
status={irResult.success ? 'success' : 'error'}
title={`${actionLabel(irResult.action)}${irResult.success ? '成功' : '失败'}`}
subTitle={irResult.no ? `红外码编号: ${irResult.no}` : undefined}
style={{ padding: '12px 0' }}
/>
</div>
)}
{/* 设备信息 */}
{info && (
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<div style={{ fontSize: 13, color: '#666' }}>设备信息</div>
<div style={{ marginTop: 4 }}>
4G 信号:<Tag color={(info.signal ?? 0) >= 20 ? 'green' : (info.signal ?? 0) >= 10 ? 'orange' : 'red'}>{info.signal ?? '--'}/31</Tag>
</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
最后上报:{info.lastSeen ? new Date(info.lastSeen).toLocaleTimeString() : '--'}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: '#666' }}>
已学习红外码:
{info.learnedCodes && info.learnedCodes.length > 0 ? (
<Space size={4} wrap style={{ marginTop: 4 }}>
{info.learnedCodes.slice().sort((a, b) => a - b).map((no) => (
<Tag key={no} color="blue" style={{ cursor: 'pointer' }} onClick={() => setEmitNo({ ...emitNo, [d.id]: no })}>
{no}
</Tag>
))}
</Space>
) : (
<span style={{ color: '#999' }}>暂无</span>
)}
</div>
</div>
)}
{/* 发射红外码 */}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>发射红外码</div>
<Space>
<InputNumber
min={1}
max={248}
value={currentNo}
onChange={(v) => setEmitNo({ ...emitNo, [d.id]: v ?? 100 })}
style={{ width: 80 }}
disabled={isLearning}
/>
<Button
type="primary"
icon={<SendOutlined />}
loading={sending === d.id + 'emit' + currentNo}
onClick={() => sendIR(d, 'emit', currentNo)}
disabled={isLearning}
>
发射
</Button>
</Space>
</div>
{/* 学习红外码 */}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>学习红外码</div>
<Space>
<InputNumber
min={1}
max={248}
value={learnModal.no}
onChange={(v) => setLearnModal({ ...learnModal, no: v ?? 100 })}
style={{ width: 80 }}
disabled={isLearning}
/>
<Button
icon={<BulbOutlined />}
loading={sending === d.id + 'learn' + learnModal.no}
onClick={() => sendIR(d, 'learn', learnModal.no)}
disabled={isLearning}
>
开始学习
</Button>
</Space>
</div>
{/* 其他操作 */}
<Space style={{ width: '100%', justifyContent: 'center' }}>
<Button
size="small"
icon={<StopOutlined />}
loading={sending === d.id + 'cancel'}
onClick={() => sendIR(d, 'cancel')}
>
取消学习
</Button>
<Button
size="small"
icon={<InfoCircleOutlined />}
loading={sending === d.id + 'info'}
onClick={() => sendIR(d, 'info')}
>
查询信息
</Button>
<Popconfirm
title="确认擦除全部红外码?"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => sendIR(d, 'erase')}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
loading={sending === d.id + 'erase'}
>
擦除全部
</Button>
</Popconfirm>
</Space>
</Col>
{/* 右侧:空调遥控面板 */}
<Col span={12}>
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: 15 }}>
空调遥控面板
{isLearning && (
<Tag color="processing" style={{ marginLeft: 8 }}>学习中...</Tag>
)}
</div>
{/* 开始学习 / 取消学习 按钮 */}
<div style={{ marginBottom: 12 }}>
{isLearning ? (
<Button
block
size="large"
danger
icon={<StopOutlined />}
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
onClick={() => cancelSequentialLearn(d.id)}
>
取消学习
</Button>
) : (
<Button
block
size="large"
type="primary"
icon={<BulbOutlined />}
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
onClick={() => startSequentialLearn(d.id)}
>
开始学习
</Button>
)}
</div>
{/* 空调控制按钮(顺序学习) */}
<Row gutter={[12, 12]}>
{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 (
<Col key={btn.key} span={12}>
<Button
block
size="large"
icon={Icon ? <Icon /> : undefined}
loading={isLoading}
className={isBlinking ? 'ir-blinking' : undefined}
style={{
height: 80,
fontSize: 16,
fontWeight: 500,
...(learned && !isBlinking
? { borderColor: '#52c41a', color: '#52c41a' }
: {}),
}}
onClick={() => emitAircon(d.id, btn)}
>
{btn.label}
</Button>
</Col>
);
})}
</Row>
{/* 温度按钮(单独学习) */}
<div style={{ marginTop: 16, marginBottom: 8, fontSize: 13, color: '#999' }}>
温度按钮(点击未学习的按钮开始单独学习)
</div>
<Row gutter={[12, 12]}>
{TEMP_BUTTONS.map((btn) => {
const learned = learnedCodes.includes(btn.no);
const isBlinking = isLearning && learningNo === btn.no;
return (
<Col key={btn.key} span={6}>
<Button
block
size="large"
className={isBlinking ? 'ir-blinking' : undefined}
style={{
height: 60,
fontSize: 15,
fontWeight: 500,
...(learned && !isBlinking
? { borderColor: '#52c41a', color: '#52c41a' }
: {}),
}}
onClick={() => {
if (isBlinking) {
// 正在学习,取消
cancelSequentialLearn(d.id);
} else if (learned) {
// 已学习,发射
emitAircon(d.id, { ...btn, icon: '' });
} else {
// 未学习,开始单独学习
learnSingleTemp(d.id, btn.no, btn.label);
}
}}
>
{btn.label}
</Button>
</Col>
);
})}
</Row>
{isLearning && (
<div style={{ marginTop: 12, fontSize: 13, color: '#666' }}>
当前学习:【{ALL_BUTTONS.find((b) => b.no === learningNo)?.label}】,请将遥控器对准设备按键
</div>
)}
</Col>
</Row>
</Card>
</Col>
);
})}
</Row>
{/* 学习中提示弹窗(保留原有功能) */}
<Modal
open={learnModal.open}
title="红外码学习中..."
onCancel={() => {
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
setLearnModal({ ...learnModal, open: false });
}}
footer={[
<Button key="cancel" onClick={() => {
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
setLearnModal({ ...learnModal, open: false });
}}>
取消学习
</Button>,
<Button key="done" type="primary" onClick={() => setLearnModal({ ...learnModal, open: false })}>
学习完成
</Button>,
]}
>
<Alert
type="info"
showIcon
message={`正在学习编号 ${learnModal.no} 的红外码`}
description={'请将遥控器对准红外控制器,按一下对应按键。听到"嘀"一声表示学习成功。'}
/>
</Modal>
</Spin>
);
};
export default IRControl;