refactor: 迁移前端到 React 19 + Zustand + Tailwind CSS v4

- 将 vanilla TS 单文件 (app.ts 395行) 拆分为 React 组件化架构
- 引入 Zustand 管理全局状态 (连接/录音/预览/历史/toast)
- 自定义 hooks 封装 WebSocket 连接和音频录制管线
- CSS 全面 Tailwind 化,style.css 从 234 行精简到 114 行 (仅保留 tokens + keyframes)
- 新增依赖: react, react-dom, zustand, @vitejs/plugin-react
- Go 后端 embed 路径 web/dist 不变,无需改动
This commit is contained in:
2026-03-02 06:36:02 +08:00
parent ea46ad71bf
commit 70344bcd98
21 changed files with 914 additions and 687 deletions

View File

@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef } from "react";
import { useAppStore } from "../stores/app-store";
function getWsUrl(): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams(location.search);
const token = params.get("token") || "";
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${proto}//${location.host}/ws${q}`;
}
const WS_RECONNECT_BASE = 1000;
const WS_RECONNECT_MAX = 16000;
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(WS_RECONNECT_BASE);
const sendJSON = useCallback((obj: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}, []);
const sendBinary = useCallback((data: Int16Array) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data.buffer);
}
}, []);
useEffect(() => {
function connect() {
if (wsRef.current) return;
useAppStore.getState().setConnectionStatus("connecting");
const ws = new WebSocket(getWsUrl());
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectDelayRef.current = WS_RECONNECT_BASE;
useAppStore.getState().setConnectionStatus("connected");
};
ws.onmessage = (e: MessageEvent) => {
if (typeof e.data !== "string") return;
try {
const msg = JSON.parse(e.data);
const store = useAppStore.getState();
switch (msg.type) {
case "partial":
store.setPreview(msg.text || "", false);
break;
case "final":
store.setPreview(msg.text || "", true);
if (msg.text) store.addHistory(msg.text);
break;
case "pasted":
store.showToast("\u2705 \u5df2\u7c98\u8d34");
break;
case "error":
store.showToast(`\u274c ${msg.message || "\u9519\u8bef"}`);
break;
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = () => {
wsRef.current = null;
const store = useAppStore.getState();
store.setConnectionStatus("disconnected");
if (store.recording) store.setRecording(false);
if (store.pendingStart) store.setPendingStart(false);
scheduleReconnect();
};
ws.onerror = () => ws.close();
wsRef.current = ws;
}
function scheduleReconnect() {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(
connect,
reconnectDelayRef.current,
);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 2,
WS_RECONNECT_MAX,
);
}
connect();
return () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
wsRef.current = null;
};
}, []);
return { sendJSON, sendBinary };
}