style: 样式优化

This commit is contained in:
2026-06-14 16:31:23 +08:00
parent fe053f94dc
commit 7a0443ebcc
8 changed files with 902 additions and 164 deletions

View File

@@ -200,7 +200,7 @@ body {
color: white;
}
/* ---- Workspace (双栏) ---- */
/* ---- Workspace (双栏,侧边栏为 overlay 不在 flex 流中) ---- */
.workspace {
flex: 1;
@@ -208,30 +208,48 @@ body {
overflow: hidden;
}
/* ---- 会话历史侧边栏 ---- */
/* ---- 会话历史侧边栏Overlay 抽屉式) ---- */
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(10, 10, 15, 0.45);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
animation: fadeIn 0.2s ease;
}
.sidebar {
width: 260px;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 51;
display: flex;
flex-direction: column;
background: var(--color-surface-1);
background: var(--color-surface);
border-right: 1px solid var(--color-border);
overflow: hidden;
animation: slideInLeft 0.3s var(--transition-smooth);
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.sidebar--collapsed {
width: 48px;
align-items: center;
padding: 8px 0;
gap: 8px;
display: none;
}
.sidebar__header {
display: flex;
align-items: center;
gap: 6px;
padding: 12px;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -245,6 +263,7 @@ body {
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.sidebar__toggle:hover {
@@ -258,9 +277,11 @@ body {
color: var(--color-text);
cursor: pointer;
font-size: 0.82rem;
padding: 6px 10px;
padding: 6px 12px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
flex: 1;
text-align: left;
}
.sidebar__new-btn:hover {
@@ -272,10 +293,38 @@ body {
text-align: left;
}
/* Sidebar Search */
.sidebar__search {
padding: 10px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.sidebar__search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text);
font-size: 0.78rem;
outline: none;
transition: border-color var(--transition-fast);
}
.sidebar__search-input::placeholder {
color: var(--color-text-muted);
}
.sidebar__search-input:focus {
border-color: var(--color-primary);
}
/* Sidebar List */
.sidebar__list {
flex: 1;
overflow-y: auto;
padding: 6px;
padding: 6px 8px;
}
.sidebar__empty {
@@ -285,6 +334,17 @@ body {
font-size: 0.82rem;
}
/* Time group header */
.sidebar__group {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
padding: 12px 12px 6px;
opacity: 0.7;
}
.sidebar__item {
display: flex;
align-items: center;
@@ -309,6 +369,13 @@ body {
border-left: 3px solid var(--color-primary);
}
.sidebar__item-icon {
font-size: 0.72rem;
color: var(--color-primary);
opacity: 0.7;
flex-shrink: 0;
}
.sidebar__item-content {
flex: 1;
min-width: 0;
@@ -371,7 +438,7 @@ body {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-1);
background: var(--color-surface-2);
color: var(--color-text);
outline: none;
}
@@ -395,6 +462,31 @@ body {
color: var(--color-text);
}
/* Sidebar footer */
.sidebar__footer {
padding: 10px 16px;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.sidebar__clear-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.72rem;
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
width: 100%;
text-align: center;
}
.sidebar__clear-btn:hover {
background: rgba(248, 113, 113, 0.08);
color: var(--color-error);
}
/* ---- 左侧视频面板 ---- */
.video-panel {
@@ -402,8 +494,8 @@ body {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 20px;
gap: 14px;
padding: 16px 20px;
gap: 10px;
}
.video-container {
@@ -533,6 +625,32 @@ body {
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
/* AI Vision Indicator (eye icon in top-right of video) */
.ai-vision-indicator {
position: absolute;
top: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 5px;
background: rgba(10, 10, 15, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--color-text-muted);
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.02em;
transition: all var(--transition-fast);
}
.ai-vision-indicator--active {
color: var(--color-success);
background: rgba(52, 211, 153, 0.15);
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
.video-overlay {
position: absolute;
inset: 0;
@@ -558,8 +676,10 @@ body {
.video-controls {
flex-shrink: 0;
display: flex;
justify-content: center;
padding: 6px 0;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.video-controls__row {
@@ -645,6 +765,12 @@ body {
letter-spacing: -0.01em;
}
.chat-panel-header__right {
display: flex;
align-items: center;
gap: 10px;
}
.chat-panel-header__mode {
font-size: 0.68rem;
font-weight: 500;
@@ -655,6 +781,17 @@ body {
letter-spacing: 0.02em;
}
.chat-panel-header__stats {
font-size: 0.68rem;
font-weight: 400;
color: var(--color-text-muted);
padding: 3px 10px;
background: var(--color-surface-2);
border-radius: 12px;
letter-spacing: 0.01em;
white-space: nowrap;
}
.chat-panel-body {
flex: 1;
overflow: hidden;
@@ -1178,3 +1315,222 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* ---- Enhanced Video Controls (3-layer) ---- */
.video-controls__toolbar {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.btn--recognize {
background: rgba(59, 130, 246, 0.1);
color: var(--color-primary);
border: 1px solid rgba(59, 130, 246, 0.25);
padding: 7px 14px;
font-size: 0.78rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
font-weight: 500;
white-space: nowrap;
}
.btn--recognize:hover {
background: rgba(59, 130, 246, 0.2);
}
.btn--recognize:active {
transform: translateY(1px);
}
/* Device selectors */
.video-controls__devices {
display: flex;
gap: 12px;
justify-content: center;
}
.device-select-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.device-select-label {
font-size: 0.65rem;
color: var(--color-text-muted);
font-weight: 500;
letter-spacing: 0.01em;
}
.device-select {
padding: 5px 10px;
font-size: 0.72rem;
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 var(--transition-fast);
min-width: 120px;
text-align: center;
}
.device-select:hover {
border-color: var(--color-surface-3);
color: var(--color-text);
}
.device-select:focus {
outline: 2px solid rgba(59, 130, 246, 0.3);
outline-offset: 1px;
}
/* Mode switcher */
.video-controls__mode {
display: flex;
gap: 2px;
background: var(--color-surface-2);
border-radius: var(--radius-sm);
padding: 2px;
justify-content: center;
}
.mode-btn {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.7rem;
font-weight: 500;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.mode-btn:hover {
color: var(--color-text);
}
.mode-btn--active {
background: var(--color-primary);
color: white;
}
/* ---- Scene Cards (empty state) ---- */
.scene-cards {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 24px;
max-width: 380px;
width: 100%;
}
.scene-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.scene-card:hover {
background: var(--color-surface-3);
border-color: var(--color-surface-3);
transform: translateY(-1px);
}
.scene-card:active {
transform: translateY(0);
}
.scene-card__icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 1px;
}
.scene-card__text {
display: flex;
flex-direction: column;
gap: 3px;
}
.scene-card__title {
font-size: 0.82rem;
font-weight: 500;
color: var(--color-text);
}
.scene-card__desc {
font-size: 0.72rem;
color: var(--color-text-muted);
line-height: 1.4;
}
/* ---- Voice Input Button ---- */
.chat-input__voice {
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text-muted);
font-size: 0.9rem;
cursor: pointer;
transition: all var(--transition-fast);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chat-input__voice:hover {
background: var(--color-surface-3);
color: var(--color-text);
}
.chat-input__voice--active {
background: rgba(52, 211, 153, 0.12);
color: var(--color-success);
border-color: rgba(52, 211, 153, 0.3);
animation: micPulse 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
/* ---- Header sidebar toggle ---- */
.header__menu-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 4px;
}
.header__menu-btn:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
.header__menu-btn:active {
transform: scale(0.92);
}

View File

@@ -1,5 +1,6 @@
// ============================================================
// CamTalk — 主应用组件Web 端栏布局:侧边栏 + 视频 + 聊天)
// CamTalk — 主应用组件Web 端栏布局:视频 + 聊天)
// 侧边栏为 overlay 抽屉式,不挤压主界面空间
// ============================================================
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -16,12 +17,16 @@ import type { Locale } from "./lib/i18n";
import type { Theme } from "./types";
import "./App.css";
/** AI 视觉模式 */
type VisionMode = "realtime" | "ondemand" | "chat";
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
function AppContent() {
const [showConfig, setShowConfig] = useState(false);
const [theme, setTheme] = useState<Theme>(loadTheme);
const [elapsed, setElapsed] = useState(0);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 切换主题时更新 <html> 的 data-theme 属性
@@ -137,6 +142,7 @@ function AppContent() {
if (connectionStatus === "connected") {
stopSession();
}
setSidebarOpen(false);
}, [createSession, setMessages, connectionStatus, stopSession]);
const handleSelectSession = useCallback((id: string) => {
@@ -160,20 +166,56 @@ function AppContent() {
}
}, [deleteSession, activeSessionId, setMessages]);
// ---- 识别画面 ----
const handleRecognize = useCallback(() => {
if (isProcessing) return;
sendTextMessage(tr("controls.recognize"));
}, [isProcessing, sendTextMessage, tr]);
// ---- 场景卡片点击 ----
const handleSceneCard = useCallback((prompt: string) => {
sendTextMessage(prompt);
}, [sendTextMessage]);
// ---- 键盘快捷键 ----
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd/Ctrl+H: 打开/关闭历史侧边栏
if ((e.metaKey || e.ctrlKey) && e.key === "h") {
e.preventDefault();
setSidebarOpen((v) => !v);
}
// Escape: 关闭侧边栏
if (e.key === "Escape" && sidebarOpen) {
setSidebarOpen(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [sidebarOpen]);
return (
<div className="app">
{/* ---- 顶部导航栏 ---- */}
<header className="header">
<div className="header__left">
{/* 侧边栏开关 */}
<button
className="header__menu-btn"
onClick={() => setSidebarOpen((v) => !v)}
title={tr("sidebar.expand")}
aria-label={tr("sidebar.expand")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 className="header__title">CamTalk</h1>
<span className="header__subtitle">{tr("app.title")}</span>
</div>
<div className="header__right">
{isConnected && stats.queryCount > 0 && (
<span className="header__stats">
{stats.queryCount} {tr("app.stats.requests")}
</span>
)}
<span className={`badge badge--${connectionStatus}`}>
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
</span>
@@ -191,6 +233,18 @@ function AppContent() {
</div>
</header>
{/* ---- 会话历史侧边栏Overlay 抽屉式) ---- */}
<SessionSidebar
sessions={sessions}
activeSessionId={activeSessionId}
open={sidebarOpen}
onToggle={() => setSidebarOpen((v) => !v)}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={renameSession}
/>
{showConfig && (
<ConfigPanel
config={config}
@@ -201,21 +255,9 @@ function AppContent() {
/>
)}
{/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
{/* ---- 主体:视频 + 聊天(两栏) ---- */}
<div className="workspace">
{/* 左侧:会话历史侧边栏 */}
<SessionSidebar
sessions={sessions}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={renameSession}
/>
{/* 中间:视频预览 + 控制栏 */}
{/* 左侧:视频预览 + 控制栏 */}
<div className="video-panel">
<div className="video-container">
<VideoPreview ref={videoRef} isStreaming={!!stream} />
@@ -228,8 +270,15 @@ function AppContent() {
{config.detailLevel === "high" && (
<div className="detail-badge">HD</div>
)}
{isObserving && (
<div className="observation-badge">{tr("video.observing")}</div>
{/* AI 视觉状态指示 */}
{isConnected && visionMode !== "chat" && (
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>{isObserving ? tr("video.observing") : "AI"}</span>
</div>
)}
{isSpeaking && (
<div className="video-indicator">{tr("video.listening")}</div>
@@ -269,37 +318,110 @@ function AppContent() {
)}
</div>
{/* 视频下方控制 */}
{/* 视频下方控制区(三层结构) */}
<div className="video-controls">
{!isConnected ? (
<button className="btn btn--primary btn--lg" onClick={startSession}>
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
</button>
) : (
<div className="video-controls__row">
<button
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
onClick={toggleCamera}
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
>
📷
<>
{/* 未连接态:主 CTA */}
<button className="btn btn--primary btn--lg" onClick={startSession}>
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
</button>
<button
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
onClick={toggleMic}
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
>
🎤
</button>
{isProcessing && (
<button className="btn btn--warning" onClick={interrupt}>
{tr("controls.interrupt")}
{/* 设备选择器 */}
<div className="video-controls__devices">
<div className="device-select-wrapper">
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
<select className="device-select" defaultValue="default">
<option value="default">{tr("controls.device.default")}</option>
</select>
</div>
<div className="device-select-wrapper">
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
<select className="device-select" defaultValue="default">
<option value="default">{tr("controls.device.default")}</option>
</select>
</div>
</div>
{/* 模式切换器 */}
<div className="video-controls__mode">
<button
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("realtime")}
>
{tr("controls.mode.realtime")}
</button>
)}
<button className="btn btn--danger" onClick={stopSession}>
{tr("controls.stop")}
</button>
</div>
<button
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("ondemand")}
>
{tr("controls.mode.ondemand")}
</button>
<button
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("chat")}
>
{tr("controls.mode.chat")}
</button>
</div>
</>
) : (
<>
{/* 通话态:核心控制工具栏 */}
<div className="video-controls__toolbar">
<button
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
onClick={toggleCamera}
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
>
📷
</button>
<button
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
onClick={toggleMic}
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
>
🎤
</button>
{/* 识别画面按钮(按需模式下显示) */}
{visionMode === "ondemand" && (
<button
className="btn--recognize"
onClick={handleRecognize}
disabled={isProcessing}
>
🔍 {tr("controls.recognize")}
</button>
)}
{isProcessing && (
<button className="btn btn--warning" onClick={interrupt}>
{tr("controls.interrupt")}
</button>
)}
<button className="btn btn--danger" onClick={stopSession}>
{tr("controls.stop")}
</button>
</div>
{/* 通话态模式切换 */}
<div className="video-controls__mode">
<button
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("realtime")}
>
{tr("controls.mode.realtime")}
</button>
<button
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("ondemand")}
>
{tr("controls.mode.ondemand")}
</button>
<button
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("chat")}
>
{tr("controls.mode.chat")}
</button>
</div>
</>
)}
</div>
</div>
@@ -308,9 +430,18 @@ function AppContent() {
<div className="chat-panel-wrapper">
<div className="chat-panel-header">
<span>{tr("chat.title")}</span>
{isConnected && mode === "observation" && (
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
)}
<div className="chat-panel-header__right">
{isConnected && mode === "observation" && (
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
)}
{isConnected && stats.queryCount > 0 && (
<span className="chat-panel-header__stats">
{stats.queryCount} {tr("statusbar.recognitions")}
{stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
{` · ${formatTime(elapsed)}`}
</span>
)}
</div>
</div>
<div className="chat-panel-body">
{connectionStatus === "disconnected" && messages.length > 0 && (
@@ -322,7 +453,11 @@ function AppContent() {
messages={messages}
currentReply={currentReply}
connectionStatus={connectionStatus}
isMicOn={isMicOn}
isSpeaking={isSpeaking}
onSendText={sendTextMessage}
onToggleMic={toggleMic}
onSceneCard={handleSceneCard}
/>
</div>
</div>

View File

@@ -1,6 +1,7 @@
// ============================================================
// ChatPanel — 消息展示面板
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
// 增强:空状态场景卡片、语音输入按钮
// ============================================================
import { useEffect, useRef, useState } from "react";
@@ -12,10 +13,33 @@ interface ChatPanelProps {
messages: ChatMessage[];
currentReply?: string;
connectionStatus: ConnectionStatus;
isMicOn?: boolean;
isSpeaking?: boolean;
onSendText?: (text: string) => void;
onToggleMic?: () => void;
onSceneCard?: (prompt: string) => void;
}
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
/** 场景卡片数据 */
function getSceneCards(t: (key: string) => string) {
return [
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
];
}
export function ChatPanel({
messages,
currentReply,
connectionStatus,
isMicOn,
isSpeaking,
onSendText,
onToggleMic,
onSceneCard,
}: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isAutoScroll = useRef(true);
@@ -23,6 +47,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
const { t } = useI18n();
const isConnected = connectionStatus === "connected";
const isEmpty = messages.length === 0 && !currentReply;
const sceneCards = getSceneCards(t);
// 用户上滚时暂停自动滚动,滚到底部时恢复
useEffect(() => {
@@ -53,15 +79,41 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
setInputText("");
};
// 点击场景卡片
const handleSceneCard = (prompt: string) => {
if (onSceneCard) {
onSceneCard(prompt);
} else if (onSendText) {
onSendText(prompt);
}
};
return (
<div className="chat-panel">
<div className="chat-panel__messages" ref={containerRef}>
{/* 空状态提示 */}
{messages.length === 0 && !currentReply && (
{/* 空状态:场景卡片 */}
{isEmpty && (
<div className="chat-panel__welcome">
<span className="chat-panel__welcome-icon">💬</span>
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
{/* 场景快捷卡片 */}
<div className="scene-cards">
{sceneCards.map((card) => (
<button
key={card.titleKey}
className="scene-card"
onClick={() => handleSceneCard(card.prompt)}
>
<span className="scene-card__icon">{card.icon}</span>
<div className="scene-card__text">
<span className="scene-card__title">{t(card.titleKey)}</span>
<span className="scene-card__desc">{t(card.descKey)}</span>
</div>
</button>
))}
</div>
</div>
)}
@@ -106,6 +158,17 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
onChange={(e) => setInputText(e.target.value)}
disabled={connectionStatus === "connecting"}
/>
{/* 语音输入按钮 */}
{onToggleMic && (
<button
type="button"
className={`chat-input__voice ${isMicOn ? "chat-input__voice--active" : ""} ${isSpeaking ? "btn--speaking" : ""}`}
onClick={onToggleMic}
title={t("chat.input.voice")}
>
🎤
</button>
)}
<button
type="submit"
className="chat-input__send"

View File

@@ -1,17 +1,18 @@
// ============================================================
// SessionSidebar — 会话历史侧边栏
// 职责:展示会话列表、新建/切换/删除/重命名会话
// SessionSidebar — 会话历史侧边栏Overlay 抽屉式)
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
// 设计overlay 覆盖在主界面之上,不挤压主界面空间
// ============================================================
import { useState } from "react";
import { useMemo, useState } from "react";
import { useI18n } from "../../lib/i18n";
import type { SessionSummary } from "../../types";
interface SessionSidebarProps {
sessions: SessionSummary[];
activeSessionId: string | null;
collapsed: boolean;
onToggleCollapse: () => void;
open: boolean;
onToggle: () => void;
onNewSession: () => void;
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
@@ -35,11 +36,27 @@ function formatRelativeTime(ts: number): string {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
/** 判断两个时间戳是否为同一天 */
function isSameDay(ts: number, ref: Date): boolean {
const d = new Date(ts);
return d.getFullYear() === ref.getFullYear() &&
d.getMonth() === ref.getMonth() &&
d.getDate() === ref.getDate();
}
type TimeGroup = "today" | "yesterday" | "earlier";
function getTimeGroup(ts: number, today: Date, yesterday: Date): TimeGroup {
if (isSameDay(ts, today)) return "today";
if (isSameDay(ts, yesterday)) return "yesterday";
return "earlier";
}
export function SessionSidebar({
sessions,
activeSessionId,
collapsed,
onToggleCollapse,
open,
onToggle,
onNewSession,
onSelectSession,
onDeleteSession,
@@ -48,6 +65,7 @@ export function SessionSidebar({
const { t } = useI18n();
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const handleStartRename = (id: string, currentTitle: string) => {
setEditingId(id);
@@ -67,94 +85,151 @@ export function SessionSidebar({
setEditTitle("");
};
if (collapsed) {
return (
<div className="sidebar sidebar--collapsed">
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.expand")}>
</button>
<button className="sidebar__new-btn" onClick={onNewSession} title={t("sidebar.new")}>
</button>
</div>
);
}
const handleSelect = (id: string) => {
onSelectSession(id);
// 选择后自动收起侧边栏
onToggle();
};
// 过滤 + 分组
const grouped = useMemo(() => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const filtered = searchQuery.trim()
? sessions.filter((s) =>
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.preview.toLowerCase().includes(searchQuery.toLowerCase())
)
: sessions;
const groups: Record<TimeGroup, SessionSummary[]> = {
today: [],
yesterday: [],
earlier: [],
};
for (const session of filtered) {
const group = getTimeGroup(session.lastActiveAt, today, yesterday);
groups[group].push(session);
}
return groups;
}, [sessions, searchQuery]);
if (!open) return null;
const groupLabels: Record<TimeGroup, string> = {
today: t("sidebar.today"),
yesterday: t("sidebar.yesterday"),
earlier: t("sidebar.earlier"),
};
return (
<div className="sidebar">
<div className="sidebar__header">
<button className="sidebar__new-btn sidebar__new-btn--full" onClick={onNewSession}>
{t("sidebar.new")}
</button>
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.collapse")}>
</button>
</div>
<>
<div className="sidebar-backdrop" onClick={onToggle} />
<div className="sidebar">
{/* 头部:新建 + 收起 */}
<div className="sidebar__header">
<button className="sidebar__new-btn" onClick={onNewSession}>
{t("sidebar.new")}
</button>
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
</button>
</div>
<div className="sidebar__list">
{sessions.length === 0 ? (
<div className="sidebar__empty">{t("sidebar.empty")}</div>
) : (
sessions.map((session) => (
<div
key={session.id}
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
onClick={() => onSelectSession(session.id)}
>
{editingId === session.id ? (
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
<input
className="sidebar__edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename();
if (e.key === "Escape") handleCancelRename();
}}
autoFocus
/>
<button className="sidebar__edit-btn" onClick={handleConfirmRename}></button>
<button className="sidebar__edit-btn" onClick={handleCancelRename}></button>
</div>
) : (
<>
<div className="sidebar__item-content">
<div className="sidebar__item-title">{session.title}</div>
<div className="sidebar__item-meta">
{session.messageCount > 0 && (
<span>{session.messageCount}{t("sidebar.messages")}</span>
{/* 搜索栏 */}
<div className="sidebar__search">
<input
className="sidebar__search-input"
type="text"
placeholder={t("sidebar.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* 会话列表(按时间分组) */}
<div className="sidebar__list">
{sessions.length === 0 ? (
<div className="sidebar__empty">{t("sidebar.empty")}</div>
) : (
(["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => {
const items = grouped[groupKey];
if (items.length === 0) return null;
return (
<div key={groupKey}>
<div className="sidebar__group">{groupLabels[groupKey]}</div>
{items.map((session) => (
<div
key={session.id}
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
onClick={() => handleSelect(session.id)}
>
{editingId === session.id ? (
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
<input
className="sidebar__edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename();
if (e.key === "Escape") handleCancelRename();
}}
autoFocus
/>
<button className="sidebar__edit-btn" onClick={handleConfirmRename}></button>
<button className="sidebar__edit-btn" onClick={handleCancelRename}></button>
</div>
) : (
<>
{/* 视频标记图标 */}
{session.messageCount > 5 && (
<span className="sidebar__item-icon" title={t("sidebar.video")}>📹</span>
)}
<div className="sidebar__item-content">
<div className="sidebar__item-title">{session.title}</div>
<div className="sidebar__item-meta">
{session.messageCount > 0 && (
<span>{session.messageCount}{t("sidebar.messages")}</span>
)}
<span>{formatRelativeTime(session.lastActiveAt)}</span>
</div>
</div>
<div className="sidebar__item-actions">
<button
className="sidebar__action-btn"
onClick={(e) => {
e.stopPropagation();
handleStartRename(session.id, session.title);
}}
title={t("sidebar.rename")}
>
</button>
<button
className="sidebar__action-btn sidebar__action-btn--danger"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
title={t("sidebar.delete")}
>
🗑
</button>
</div>
</>
)}
<span>{formatRelativeTime(session.lastActiveAt)}</span>
</div>
</div>
<div className="sidebar__item-actions">
<button
className="sidebar__action-btn"
onClick={(e) => {
e.stopPropagation();
handleStartRename(session.id, session.title);
}}
title={t("sidebar.rename")}
>
</button>
<button
className="sidebar__action-btn sidebar__action-btn--danger"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
title={t("sidebar.delete")}
>
🗑
</button>
</div>
</>
)}
</div>
))
)}
))}
</div>
);
})
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -482,5 +482,6 @@ export function useVisionSession() {
toggleCamera,
toggleMic,
sendTextMessage,
captureFrame,
};
}

View File

@@ -89,6 +89,42 @@ export const enUS: TranslationMap = {
"sidebar.messages": " messages",
"sidebar.rename": "Rename",
"sidebar.delete": "Delete",
"sidebar.search": "Search conversations...",
"sidebar.today": "Today",
"sidebar.yesterday": "Yesterday",
"sidebar.earlier": "Earlier",
"sidebar.pinned": "Pinned",
"sidebar.video": "Video",
"sidebar.clearHistory": "Clear History",
// Scene cards (empty state)
"scene.describe": "Describe my surroundings",
"scene.describe.desc": "Let AI observe and describe the current scene",
"scene.text": "Recognize text in view",
"scene.text.desc": "Extract and translate text from the scene",
"scene.object": "Analyze this object",
"scene.object.desc": "Identify objects and provide information",
"scene.suggest": "Give me suggestions",
"scene.suggest.desc": "Practical suggestions based on the current scene",
// Video controls (enhanced)
"controls.recognize": "Analyze Scene",
"controls.device.camera": "Camera",
"controls.device.mic": "Microphone",
"controls.device.default": "Default",
"controls.mode.realtime": "Realtime",
"controls.mode.ondemand": "On-demand",
"controls.mode.chat": "Chat only",
// Status bar
"statusbar.ready": "Ready · Select devices to start",
"statusbar.calling": "In call",
"statusbar.duration": "Duration",
"statusbar.recognitions": "recognitions",
"statusbar.tokens": "Tokens",
// Input enhanced
"chat.input.voice": "Voice input",
// Device errors
"error.vadInit": "VAD initialization failed",

View File

@@ -89,6 +89,42 @@ export const jaJP: TranslationMap = {
"sidebar.messages": "件のメッセージ",
"sidebar.rename": "名前を変更",
"sidebar.delete": "削除",
"sidebar.search": "会話を検索...",
"sidebar.today": "今日",
"sidebar.yesterday": "昨日",
"sidebar.earlier": "それ以前",
"sidebar.pinned": "ピン留め",
"sidebar.video": "ビデオ",
"sidebar.clearHistory": "履歴をクリア",
// Scene cards (empty state)
"scene.describe": "周囲のシーンを説明して",
"scene.describe.desc": "AIが現在のシーンを観察して説明します",
"scene.text": "画面のテキストを認識",
"scene.text.desc": "シーン内のテキストを抽出・翻訳します",
"scene.object": "この物を分析して",
"scene.object.desc": "物体を識別して関連情報を提供します",
"scene.suggest": "アドバイスをください",
"scene.suggest.desc": "現在のシーンに基づいた実用的な提案",
// Video controls (enhanced)
"controls.recognize": "シーンを分析",
"controls.device.camera": "カメラ",
"controls.device.mic": "マイク",
"controls.device.default": "デフォルト",
"controls.mode.realtime": "リアルタイム",
"controls.mode.ondemand": "オンデマンド",
"controls.mode.chat": "チャットのみ",
// Status bar
"statusbar.ready": "準備完了 · デバイスを選択して開始",
"statusbar.calling": "通話中",
"statusbar.duration": "通話時間",
"statusbar.recognitions": "回の認識",
"statusbar.tokens": "トークン",
// Input enhanced
"chat.input.voice": "音声入力",
// Device errors
"error.vadInit": "VAD初期化に失敗しました",

View File

@@ -89,6 +89,42 @@ export const zhCN: TranslationMap = {
"sidebar.messages": "条消息",
"sidebar.rename": "重命名",
"sidebar.delete": "删除",
"sidebar.search": "搜索对话...",
"sidebar.today": "今天",
"sidebar.yesterday": "昨天",
"sidebar.earlier": "更早",
"sidebar.pinned": "置顶",
"sidebar.video": "视频",
"sidebar.clearHistory": "清除历史",
// Scene cards (empty state)
"scene.describe": "描述我面前的场景",
"scene.describe.desc": "让 AI 观察并描述当前画面",
"scene.text": "识别画面中的文字",
"scene.text.desc": "提取并翻译画面里的文字",
"scene.object": "帮我分析这个物品",
"scene.object.desc": "识别物体并给出相关信息",
"scene.suggest": "给我一些建议",
"scene.suggest.desc": "基于当前场景给出实用建议",
// Video controls (enhanced)
"controls.recognize": "识别画面",
"controls.device.camera": "摄像头",
"controls.device.mic": "麦克风",
"controls.device.default": "默认",
"controls.mode.realtime": "实时分析",
"controls.mode.ondemand": "按需识别",
"controls.mode.chat": "纯聊天",
// Status bar
"statusbar.ready": "就绪 · 选择设备后开始通话",
"statusbar.calling": "通话中",
"statusbar.duration": "通话时长",
"statusbar.recognitions": "次识别",
"statusbar.tokens": "Token",
// Input enhanced
"chat.input.voice": "语音输入",
// Device errors
"error.vadInit": "VAD 初始化失败",