Files
opsxshell/src/components/serial/SerialConnectDialog.tsx
T

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>
)
}