fix(local-tool): Review minor m1/m2/m4 修復

m1(i18n 混用不一致):
- flash-dialog.tsx 所有英文字串改繁中(載入模型 / 選擇模型 / 硬體不相容 / 開始載入 / 關閉 / 完成)
- flash-progress.tsx 同理(模型載入失敗 / 重試 / 正在準備載入 / 模型載入完成)

m2(Dialog 關閉防護不完整):
- 改用 isInProgress 判斷(isFlashing || progress 未到 100%)+ 沒 error
  而非只看 isFlashing,涵蓋「API 回應了但 WS 進度還在跑」的情況

m4(WS 3 秒 timeout 應 reject):
- connectAndWait timeout 改 reject + Error message
- flash-dialog handleFlash 加 try/catch 捕捉 WS 連線失敗
  → 呼叫 setError 讓 UI 顯示錯誤而非靜默卡住

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 20:21:18 +08:00
parent 3c6971febd
commit c24a04cdb2
3 changed files with 34 additions and 26 deletions

View File

@ -39,6 +39,7 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
const device = devices.find((d) => d.id === deviceId); const device = devices.find((d) => d.id === deviceId);
const selectedModel = models.find((m) => m.id === selectedModelId); const selectedModel = models.find((m) => m.id === selectedModelId);
// S2: 資料載入前預設 compatible=true避免在 model/device 還沒載入時就顯示不相容警告
const compatible = useMemo(() => { const compatible = useMemo(() => {
if (!selectedModel || !device) return true; if (!selectedModel || !device) return true;
return isModelCompatible(selectedModel.supportedHardware, device.type); return isModelCompatible(selectedModel.supportedHardware, device.type);
@ -56,30 +57,38 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
const handleFlash = async () => { const handleFlash = async () => {
if (!selectedModelId) return; if (!selectedModelId) return;
// 1. Create WebSocket and wait for it to open try {
await connectAndWait(); await connectAndWait();
// 2. Then start flash (POST) — now WS is listening } catch (e) {
const msg = e instanceof Error ? e.message : String(e);
reset();
useFlashStore.getState().setError(msg);
return;
}
await startFlash(deviceId, selectedModelId); await startFlash(deviceId, selectedModelId);
}; };
// m2 fix: 進度尚未完成時(不管 isFlashing 狀態如何)都阻止關閉
const isInProgress = (isFlashing || (progress !== null && progress.percent < 100)) && !error;
return ( return (
<Dialog open={open} onOpenChange={(v) => { <Dialog open={open} onOpenChange={(v) => {
if (!v && isFlashing && !error) return; if (!v && isInProgress) return;
setOpen(v); setOpen(v);
}}> }}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button data-tour-id="flash-model-btn">Flash Model</Button> <Button data-tour-id="flash-model-btn"></Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Flash Model to Device</DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{!isFlashing && !progress && !error ? ( {!isFlashing && !progress && !error ? (
<> <>
<Select value={selectedModelId} onValueChange={setSelectedModelId}> <Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a model" /> <SelectValue placeholder="選擇模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{models.map((m) => ( {models.map((m) => (
@ -95,9 +104,9 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<TriangleAlertIcon className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" /> <TriangleAlertIcon className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-yellow-800">Hardware Incompatible</p> <p className="font-medium text-yellow-800"></p>
<p className="text-yellow-700"> <p className="text-yellow-700">
This model may not be compatible with {device ? getHardwareType(device.type) : 'this device'}. {device ? getHardwareType(device.type) : '此裝置'}
</p> </p>
</div> </div>
</div> </div>
@ -110,10 +119,10 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
className="w-full" className="w-full"
> >
{!selectedModelId {!selectedModelId
? 'Select a model' ? '請先選擇模型'
: !compatible : !compatible
? 'Incompatible — Cannot Flash' ? '不相容 — 無法載入'
: 'Start Flash'} : '開始載入'}
</Button> </Button>
</> </>
) : ( ) : (
@ -130,7 +139,7 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
setOpen(false); setOpen(false);
}} }}
> >
{error ? 'Close' : 'Done'} {error ? '關閉' : '完成'}
</Button> </Button>
)} )}
</div> </div>

View File

@ -19,14 +19,14 @@ export function FlashProgress({ progress, error, onRetry }: FlashProgressProps)
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" /> <XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-red-800">Flash Failed</p> <p className="font-medium text-red-800"></p>
<p className="text-sm text-red-700 mt-1">{error}</p> <p className="text-sm text-red-700 mt-1">{error}</p>
</div> </div>
</div> </div>
</div> </div>
{onRetry && ( {onRetry && (
<Button onClick={onRetry} className="w-full" variant="outline"> <Button onClick={onRetry} className="w-full" variant="outline">
Retry
</Button> </Button>
)} )}
</div> </div>
@ -37,7 +37,7 @@ export function FlashProgress({ progress, error, onRetry }: FlashProgressProps)
return ( return (
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="animate-pulse text-sm text-muted-foreground"> <div className="animate-pulse text-sm text-muted-foreground">
Preparing flash... ...
</div> </div>
<Progress value={0} /> <Progress value={0} />
</div> </div>
@ -53,7 +53,7 @@ export function FlashProgress({ progress, error, onRetry }: FlashProgressProps)
<Progress value={progress.percent} /> <Progress value={progress.percent} />
<p className="text-sm text-muted-foreground">{progress.message}</p> <p className="text-sm text-muted-foreground">{progress.message}</p>
{progress.percent >= 100 && ( {progress.percent >= 100 && (
<p className="text-sm font-medium text-green-600">Flash Complete!</p> <p className="text-sm font-medium text-green-600"></p>
)} )}
</div> </div>
); );

View File

@ -29,16 +29,15 @@ export function useFlashProgress(deviceId: string) {
*/ */
const connectAndWait = useCallback( const connectAndWait = useCallback(
() => () =>
new Promise<void>((resolve) => { new Promise<void>((resolve, reject) => {
// Close any existing connection
wsRef.current?.close(); wsRef.current?.close();
let resolved = false; let settled = false;
const doResolve = () => { const doResolve = () => {
if (!resolved) { if (!settled) { settled = true; resolve(); }
resolved = true; };
resolve(); const doReject = (reason: string) => {
} if (!settled) { settled = true; reject(new Error(reason)); }
}; };
const ws = createWebSocket( const ws = createWebSocket(
@ -52,8 +51,8 @@ export function useFlashProgress(deviceId: string) {
); );
wsRef.current = ws; wsRef.current = ws;
// Safety timeout — don't block forever // m4 fix: timeout 時 reject 而非 resolve讓 UI 顯示錯誤而非靜默繼續
setTimeout(doResolve, 3000); setTimeout(() => doReject('WebSocket 連線逾時3 秒),請確認伺服器是否正常運作'), 3000);
}), }),
[deviceId, updateProgress], [deviceId, updateProgress],
); );