199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Cable, RefreshCw } from 'lucide-react'
|
|
import Modal from '../common/Modal'
|
|
import { listSerialPorts } from '../../platform/serial'
|
|
import type {
|
|
SerialConfig,
|
|
SerialFlowControl,
|
|
SerialParity,
|
|
SerialPortSummary,
|
|
} from '../../../shared/types/serial.types'
|
|
|
|
interface SerialConnectDialogProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onConnect: (config: SerialConfig) => void
|
|
}
|
|
|
|
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]
|
|
|
|
export default function SerialConnectDialog({
|
|
isOpen,
|
|
onClose,
|
|
onConnect,
|
|
}: SerialConnectDialogProps) {
|
|
const [ports, setPorts] = useState<SerialPortSummary[]>([])
|
|
const [path, setPath] = useState('/dev/tty.debug-console')
|
|
const [baudRate, setBaudRate] = useState(115200)
|
|
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8)
|
|
const [stopBits, setStopBits] = useState<1 | 2>(1)
|
|
const [parity, setParity] = useState<SerialParity>('none')
|
|
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none')
|
|
const [loadingPorts, setLoadingPorts] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const refreshPorts = useCallback(async () => {
|
|
setLoadingPorts(true)
|
|
setError(null)
|
|
try {
|
|
const detected = await listSerialPorts()
|
|
setPorts(detected)
|
|
setPath((currentPath) =>
|
|
detected.length > 0 && !detected.some((port) => port.path === currentPath)
|
|
? detected[0].path
|
|
: currentPath
|
|
)
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : String(reason))
|
|
} finally {
|
|
setLoadingPorts(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) void refreshPorts()
|
|
}, [isOpen, refreshPorts])
|
|
|
|
const handleConnect = () => {
|
|
if (!path.trim()) {
|
|
setError('请输入串口设备路径')
|
|
return
|
|
}
|
|
onConnect({
|
|
path: path.trim(),
|
|
baudRate,
|
|
dataBits,
|
|
stopBits,
|
|
parity,
|
|
flowControl,
|
|
})
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
onConfirm={handleConnect}
|
|
title="连接串口"
|
|
description="原生串口终端 · 适用于路由器、交换机、开发板与调试控制台"
|
|
icon={<Cable className="w-5 h-5" />}
|
|
confirmText="打开串口"
|
|
size="lg"
|
|
>
|
|
<div className="space-y-4">
|
|
<section className="rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="serial-path" className="ui-label mb-0">设备</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refreshPorts()}
|
|
className="inline-flex items-center gap-1 text-[11px] text-txt-muted hover:text-content-primary"
|
|
>
|
|
<RefreshCw className={`w-3 h-3 ${loadingPorts ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
<input
|
|
id="serial-path"
|
|
value={path}
|
|
onChange={(event) => setPath(event.target.value)}
|
|
placeholder="/dev/tty.debug-console"
|
|
className="ui-input font-mono"
|
|
spellCheck={false}
|
|
/>
|
|
{ports.length > 0 ? (
|
|
<div className="grid gap-1.5 max-h-32 overflow-y-auto">
|
|
{ports.map((port) => (
|
|
<button
|
|
key={port.path}
|
|
type="button"
|
|
onClick={() => setPath(port.path)}
|
|
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
|
path === port.path
|
|
? 'border-accent-blue/60 bg-accent-blue/10 text-content-primary'
|
|
: 'border-dark-700/50 hover:bg-dark-700/40 text-content-secondary'
|
|
}`}
|
|
>
|
|
<span className="font-mono text-xs truncate">{port.path}</span>
|
|
<span className="text-[10px] text-txt-faint flex-shrink-0">{port.portType}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-[11px] text-txt-faint">
|
|
未枚举到设备时可直接输入路径,例如 /dev/tty.debug-console 或
|
|
/dev/tty.Bluetooth-Incoming-Port。
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="grid grid-cols-2 sm:grid-cols-3 gap-3 rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5">
|
|
<div>
|
|
<label htmlFor="serial-baud" className="ui-label">波特率</label>
|
|
<select
|
|
id="serial-baud"
|
|
value={baudRate}
|
|
onChange={(event) => setBaudRate(Number(event.target.value))}
|
|
className="ui-input-sm"
|
|
>
|
|
{BAUD_RATES.map((rate) => <option key={rate} value={rate}>{rate}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="serial-data-bits" className="ui-label">数据位</label>
|
|
<select
|
|
id="serial-data-bits"
|
|
value={dataBits}
|
|
onChange={(event) => setDataBits(Number(event.target.value) as 5 | 6 | 7 | 8)}
|
|
className="ui-input-sm"
|
|
>
|
|
{[5, 6, 7, 8].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="serial-stop-bits" className="ui-label">停止位</label>
|
|
<select
|
|
id="serial-stop-bits"
|
|
value={stopBits}
|
|
onChange={(event) => setStopBits(Number(event.target.value) as 1 | 2)}
|
|
className="ui-input-sm"
|
|
>
|
|
<option value={1}>1</option>
|
|
<option value={2}>2</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="serial-parity" className="ui-label">校验</label>
|
|
<select
|
|
id="serial-parity"
|
|
value={parity}
|
|
onChange={(event) => setParity(event.target.value as SerialParity)}
|
|
className="ui-input-sm"
|
|
>
|
|
<option value="none">无</option>
|
|
<option value="odd">奇校验</option>
|
|
<option value="even">偶校验</option>
|
|
</select>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label htmlFor="serial-flow-control" className="ui-label">流控</label>
|
|
<select
|
|
id="serial-flow-control"
|
|
value={flowControl}
|
|
onChange={(event) => setFlowControl(event.target.value as SerialFlowControl)}
|
|
className="ui-input-sm"
|
|
>
|
|
<option value="none">无</option>
|
|
<option value="software">软件 XON/XOFF</option>
|
|
<option value="hardware">硬件 RTS/CTS</option>
|
|
</select>
|
|
</div>
|
|
</section>
|
|
|
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|