refactor: 使用 sonner、zustand persist、partysocket 替换手写实现

This commit is contained in:
2026-03-02 06:57:45 +08:00
parent 08e5abe165
commit ab60db0dc5
9 changed files with 98 additions and 158 deletions

View File

@@ -5,8 +5,10 @@
"": { "": {
"name": "web", "name": "web",
"dependencies": { "dependencies": {
"partysocket": "^1.1.16",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sonner": "^2.0.7",
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
@@ -262,6 +264,8 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -312,6 +316,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"partysocket": ["partysocket@1.1.16", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -330,6 +336,8 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],

View File

@@ -20,8 +20,10 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"partysocket": "^1.1.16",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sonner": "^2.0.7",
"zustand": "^5.0.11" "zustand": "^5.0.11"
} }
} }

View File

@@ -1,8 +1,8 @@
import { Toaster } from "sonner";
import { HistoryList } from "./components/HistoryList"; import { HistoryList } from "./components/HistoryList";
import { MicButton } from "./components/MicButton"; import { MicButton } from "./components/MicButton";
import { PreviewBox } from "./components/PreviewBox"; import { PreviewBox } from "./components/PreviewBox";
import { StatusBadge } from "./components/StatusBadge"; import { StatusBadge } from "./components/StatusBadge";
import { Toast } from "./components/Toast";
import { useRecorder } from "./hooks/useRecorder"; import { useRecorder } from "./hooks/useRecorder";
import { useWebSocket } from "./hooks/useWebSocket"; import { useWebSocket } from "./hooks/useWebSocket";
@@ -27,7 +27,7 @@ export function App() {
<MicButton onStart={startRecording} onStop={stopRecording} /> <MicButton onStart={startRecording} onStop={stopRecording} />
<HistoryList sendJSON={sendJSON} /> <HistoryList sendJSON={sendJSON} />
</div> </div>
<Toast /> <Toaster theme="dark" position="bottom-center" />
</> </>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { toast } from "sonner";
import { useAppStore } from "../stores/app-store"; import { useAppStore } from "../stores/app-store";
function formatTime(ts: number): string { function formatTime(ts: number): string {
@@ -13,14 +14,13 @@ interface HistoryListProps {
export function HistoryList({ sendJSON }: HistoryListProps) { export function HistoryList({ sendJSON }: HistoryListProps) {
const history = useAppStore((s) => s.history); const history = useAppStore((s) => s.history);
const clearHistory = useAppStore((s) => s.clearHistory); const clearHistory = useAppStore((s) => s.clearHistory);
const showToast = useAppStore((s) => s.showToast);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(text: string) => { (text: string) => {
sendJSON({ type: "paste", text }); sendJSON({ type: "paste", text });
showToast("发送粘贴…"); toast.info("发送粘贴…");
}, },
[sendJSON, showToast], [sendJSON],
); );
return ( return (
@@ -39,9 +39,7 @@ export function HistoryList({ sendJSON }: HistoryListProps) {
</div> </div>
{history.length === 0 ? ( {history.length === 0 ? (
<p className="py-10 text-center text-fg-dim text-sm"> <p className="py-10 text-center text-fg-dim text-sm">{"暂无记录"}</p>
{"暂无记录"}
</p>
) : ( ) : (
<div className="scrollbar-thin flex-1 overflow-y-auto"> <div className="scrollbar-thin flex-1 overflow-y-auto">
{history.map((item, i) => ( {history.map((item, i) => (

View File

@@ -98,9 +98,7 @@ export function MicButton({ onStart, onStop }: MicButtonProps) {
))} ))}
</div> </div>
</div> </div>
<p className="font-medium text-fg-dim text-sm"> <p className="font-medium text-fg-dim text-sm">{"按住说话"}</p>
{"按住说话"}
</p>
</section> </section>
); );
} }

View File

@@ -1,29 +0,0 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../stores/app-store";
export function Toast() {
const toast = useAppStore((s) => s.toast);
const dismissToast = useAppStore((s) => s.dismissToast);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!toast) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(dismissToast, 2000);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast, dismissToast]);
return (
<div
className={`pointer-events-none fixed bottom-[calc(80px+env(safe-area-inset-bottom,0px))] left-1/2 z-[999] rounded-3xl border border-edge bg-[rgba(28,28,37,0.85)] px-[22px] py-2.5 font-medium text-[13px] text-fg shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-[16px] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
toast
? "-translate-x-1/2 translate-y-0 opacity-100"
: "-translate-x-1/2 translate-y-2 opacity-0"
}`}
>
{toast}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { resampleTo16kInt16 } from "../lib/resample"; import { resampleTo16kInt16 } from "../lib/resample";
import { useAppStore } from "../stores/app-store"; import { useAppStore } from "../stores/app-store";
import audioProcessorUrl from "../workers/audio-processor.ts?worker&url"; import audioProcessorUrl from "../workers/audio-processor.ts?worker&url";
@@ -89,9 +90,7 @@ export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) {
} catch (err) { } catch (err) {
useAppStore.getState().setPendingStart(false); useAppStore.getState().setPendingStart(false);
abortRef.current = null; abortRef.current = null;
useAppStore toast.error(`麦克风错误: ${(err as Error).message}`);
.getState()
.showToast(`麦克风错误: ${(err as Error).message}`);
} }
}, [initAudio]); }, [initAudio]);

View File

@@ -1,4 +1,6 @@
import { WebSocket as ReconnectingWebSocket } from "partysocket";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";
import { useAppStore } from "../stores/app-store"; import { useAppStore } from "../stores/app-store";
function getWsUrl(): string { function getWsUrl(): string {
@@ -9,13 +11,8 @@ function getWsUrl(): string {
return `${proto}//${location.host}/ws${q}`; return `${proto}//${location.host}/ws${q}`;
} }
const WS_RECONNECT_BASE = 1000;
const WS_RECONNECT_MAX = 16000;
export function useWebSocket() { export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<ReconnectingWebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(WS_RECONNECT_BASE);
const sendJSON = useCallback((obj: Record<string, unknown>) => { const sendJSON = useCallback((obj: Record<string, unknown>) => {
const ws = wsRef.current; const ws = wsRef.current;
@@ -27,20 +24,20 @@ export function useWebSocket() {
const sendBinary = useCallback((data: Int16Array) => { const sendBinary = useCallback((data: Int16Array) => {
const ws = wsRef.current; const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
ws.send(data.buffer); ws.send(data.buffer as ArrayBuffer);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
function connect() {
if (wsRef.current) return;
useAppStore.getState().setConnectionStatus("connecting"); useAppStore.getState().setConnectionStatus("connecting");
const ws = new WebSocket(getWsUrl());
const ws = new ReconnectingWebSocket(getWsUrl(), undefined, {
minReconnectionDelay: 1000,
maxReconnectionDelay: 16000,
});
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { ws.onopen = () => {
reconnectDelayRef.current = WS_RECONNECT_BASE;
useAppStore.getState().setConnectionStatus("connected"); useAppStore.getState().setConnectionStatus("connected");
}; };
@@ -58,10 +55,10 @@ export function useWebSocket() {
if (msg.text) store.addHistory(msg.text); if (msg.text) store.addHistory(msg.text);
break; break;
case "pasted": case "pasted":
store.showToast("已粘贴"); toast.success("已粘贴");
break; break;
case "error": case "error":
store.showToast(`${msg.message || "错误"}`); toast.error(msg.message || "错误");
break; break;
} }
} catch { } catch {
@@ -70,35 +67,16 @@ export function useWebSocket() {
}; };
ws.onclose = () => { ws.onclose = () => {
wsRef.current = null;
const store = useAppStore.getState(); const store = useAppStore.getState();
store.setConnectionStatus("disconnected"); store.setConnectionStatus("disconnected");
if (store.recording) store.setRecording(false); if (store.recording) store.setRecording(false);
if (store.pendingStart) store.setPendingStart(false); if (store.pendingStart) store.setPendingStart(false);
scheduleReconnect();
}; };
ws.onerror = () => ws.close();
wsRef.current = ws; 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 () => { return () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); ws.close();
wsRef.current?.close();
wsRef.current = null; wsRef.current = null;
}; };
}, []); }, []);

View File

@@ -1,11 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface HistoryItem { export interface HistoryItem {
text: string; text: string;
ts: number; ts: number;
} }
const HISTORY_KEY = "voicepaste_history";
const HISTORY_MAX = 50; const HISTORY_MAX = 50;
type ConnectionStatus = "connected" | "disconnected" | "connecting"; type ConnectionStatus = "connected" | "disconnected" | "connecting";
@@ -21,8 +21,6 @@ interface AppState {
previewActive: boolean; previewActive: boolean;
// History // History
history: HistoryItem[]; history: HistoryItem[];
// Toast
toast: string | null;
// Actions // Actions
setConnectionStatus: (status: ConnectionStatus) => void; setConnectionStatus: (status: ConnectionStatus) => void;
@@ -32,28 +30,17 @@ interface AppState {
clearPreview: () => void; clearPreview: () => void;
addHistory: (text: string) => void; addHistory: (text: string) => void;
clearHistory: () => void; clearHistory: () => void;
showToast: (message: string) => void;
dismissToast: () => void;
} }
function loadHistoryFromStorage(): HistoryItem[] { export const useAppStore = create<AppState>()(
try { persist(
return JSON.parse( (set, get) => ({
localStorage.getItem(HISTORY_KEY) || "[]",
) as HistoryItem[];
} catch {
return [];
}
}
export const useAppStore = create<AppState>((set, get) => ({
connectionStatus: "connecting", connectionStatus: "connecting",
recording: false, recording: false,
pendingStart: false, pendingStart: false,
previewText: "", previewText: "",
previewActive: false, previewActive: false,
history: loadHistoryFromStorage(), history: [],
toast: null,
setConnectionStatus: (connectionStatus) => set({ connectionStatus }), setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
setRecording: (recording) => set({ recording }), setRecording: (recording) => set({ recording }),
@@ -72,15 +59,14 @@ export const useAppStore = create<AppState>((set, get) => ({
0, 0,
HISTORY_MAX, HISTORY_MAX,
); );
localStorage.setItem(HISTORY_KEY, JSON.stringify(items));
set({ history: items }); set({ history: items });
}, },
clearHistory: () => { clearHistory: () => set({ history: [] }),
localStorage.removeItem(HISTORY_KEY); }),
set({ history: [] }); {
name: "voicepaste_history",
partialize: (state) => ({ history: state.history }),
}, },
),
showToast: (message) => set({ toast: message }), );
dismissToast: () => set({ toast: null }),
}));