diff --git a/frontend/src/App.css b/frontend/src/App.css index b6260b9..330c48c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -143,6 +143,35 @@ body { color: var(--color-text); } +.lang-group { + display: flex; + gap: 2px; + background: var(--color-surface-2); + border-radius: var(--radius-sm); + padding: 2px; +} + +.lang-btn { + background: none; + border: none; + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 600; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.lang-btn:hover { + color: var(--color-text); +} + +.lang-btn--active { + background: var(--color-primary); + color: white; +} + /* ---- Workspace (双栏) ---- */ .workspace { @@ -154,7 +183,7 @@ body { /* ---- 左侧视频面板 ---- */ .video-panel { - width: 400px; + width: 420px; flex-shrink: 0; display: flex; flex-direction: column; @@ -216,6 +245,31 @@ body { animation: pulse 1.5s ease-in-out infinite; } +.live-badge { + position: absolute; + top: 12px; + left: 12px; + display: flex; + align-items: center; + gap: 6px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + color: white; + padding: 4px 12px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.5px; +} + +.live-badge__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-success); + animation: pulse 1.5s ease-in-out infinite; +} + .video-indicator--loading { color: var(--color-warning); } .video-indicator--audio { left: auto; right: 16px; color: var(--color-primary); } .video-indicator--error { color: var(--color-error); animation: none; } @@ -278,7 +332,48 @@ body { .video-controls__row { display: flex; - gap: 8px; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.btn--ctrl { + padding: 6px 12px; + font-size: 0.78rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn--ctrl:hover { + background: var(--color-border); + color: var(--color-text); +} + +.btn--ctrl-on { + background: rgba(34, 197, 94, 0.15); + color: var(--color-success); + border-color: rgba(34, 197, 94, 0.3); +} + +.btn--ctrl-off { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); + border-color: rgba(239, 68, 68, 0.2); +} + +.btn--speaking { + animation: micPulse 0.8s ease-in-out infinite; + box-shadow: 0 0 12px rgba(34, 197, 94, 0.5); +} + +@keyframes micPulse { + 0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); transform: scale(1); } + 50% { box-shadow: 0 0 16px rgba(34, 197, 94, 0.8); transform: scale(1.05); } } /* ---- 右侧聊天面板 ---- */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bde81ef..ffd567b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ // CamTalk — 主应用组件(Web 端双栏布局) // ============================================================ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useVisionSession } from "./hooks/useVisionSession"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; @@ -15,6 +15,8 @@ import "./App.css"; function App() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); + const [elapsed, setElapsed] = useState(0); + const timerRef = useRef | null>(null); // 切换主题时更新 的 data-theme 属性 useEffect(() => { @@ -26,6 +28,12 @@ function App() { saveTheme(t); }, []); + const formatTime = (s: number) => { + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; + }; + const { messages, currentReply, @@ -46,10 +54,39 @@ function App() { startSession, stopSession, interrupt, + isCameraOn, + isMicOn, + toggleCamera, + toggleMic, } = useVisionSession(); const isConnected = connectionStatus === "connected"; + // 连接计时器 + const elapsedRef = useRef(0); + useEffect(() => { + if (isConnected) { + elapsedRef.current = 0; + setElapsed(0); // eslint-disable-line react-hooks/set-state-in-effect -- 连接时重置计时器 + } + }, [isConnected]); + + useEffect(() => { + if (!isConnected) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return; + } + const id = setInterval(() => { + elapsedRef.current += 1; + setElapsed(elapsedRef.current); + }, 1000); + timerRef.current = id; + return () => clearInterval(id); + }, [isConnected]); + return (
{/* ---- 顶部导航栏 ---- */} @@ -93,6 +130,12 @@ function App() {
+ {isConnected && ( +
+ + LIVE {formatTime(elapsed)} +
+ )} {config.detailLevel === "high" && (
HD
)} @@ -132,6 +175,20 @@ function App() { ) : (
+ +