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:
106
web/src/components/MicButton.tsx
Normal file
106
web/src/components/MicButton.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } from "../stores/app-store";
|
||||
|
||||
interface MicButtonProps {
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function MicButton({ onStart, onStop }: MicButtonProps) {
|
||||
const connected = useAppStore((s) => s.connectionStatus === "connected");
|
||||
const recording = useAppStore((s) => s.recording);
|
||||
const pendingStart = useAppStore((s) => s.pendingStart);
|
||||
const isActive = recording || pendingStart;
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
onStart();
|
||||
},
|
||||
[onStart],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
onStop();
|
||||
},
|
||||
[onStop],
|
||||
);
|
||||
|
||||
// Read latest state to avoid stale closures
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const s = useAppStore.getState();
|
||||
if (s.recording || s.pendingStart) onStop();
|
||||
}, [onStop]);
|
||||
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
const s = useAppStore.getState();
|
||||
if (s.recording || s.pendingStart) onStop();
|
||||
}, [onStop]);
|
||||
|
||||
const disabledClasses =
|
||||
"cursor-not-allowed border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary opacity-30 shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
|
||||
const activeClasses =
|
||||
"animate-mic-breathe scale-[1.06] border-accent-hover bg-accent text-white shadow-[0_0_32px_rgba(99,102,241,0.35),0_0_80px_rgba(99,102,241,0.2)]";
|
||||
const idleClasses =
|
||||
"border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
|
||||
|
||||
return (
|
||||
<section className="flex shrink-0 flex-col items-center gap-3.5 pt-5 pb-4">
|
||||
<div className="relative flex touch-none items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!connected}
|
||||
className={`relative z-1 flex size-24 cursor-pointer touch-none select-none items-center justify-center rounded-full border-2 transition-all duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] ${
|
||||
!connected
|
||||
? disabledClasses
|
||||
: isActive
|
||||
? activeClasses
|
||||
: idleClasses
|
||||
}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={48}
|
||||
height={48}
|
||||
fill="currentColor"
|
||||
aria-label="\u9ea6\u514b\u98ce"
|
||||
role="img"
|
||||
>
|
||||
<title>{"\u9ea6\u514b\u98ce"}</title>
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Wave rings */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{[0, 0.8, 1.6].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className={`absolute inset-0 rounded-full border-[1.5px] border-accent ${
|
||||
isActive ? "animate-ring-expand" : "opacity-0"
|
||||
}`}
|
||||
style={isActive ? { animationDelay: `${delay}s` } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-medium text-fg-dim text-sm">
|
||||
{"\u6309\u4f4f\u8bf4\u8bdd"}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user