增加主题切换功能,优化设置界面的面板 #43

Merged
cfy777 merged 2 commits from develop-frontend7 into develop 2026-06-13 17:16:30 +08:00
5 changed files with 233 additions and 70 deletions

View File

@@ -19,6 +19,20 @@
--radius-sm: 6px; --radius-sm: 6px;
} }
[data-theme="light"] {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-surface-2: #f8fafc;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-success: #16a34a;
--color-warning: #d97706;
--color-error: #dc2626;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -140,7 +154,7 @@ body {
/* ---- 左侧视频面板 ---- */ /* ---- 左侧视频面板 ---- */
.video-panel { .video-panel {
width: 340px; width: 400px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -151,7 +165,7 @@ body {
.video-container { .video-container {
position: relative; position: relative;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
background: #0a0e17; background: var(--color-surface-2);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -167,7 +181,7 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
background: #000; background: #1a1a2e;
} }
.video-placeholder { .video-placeholder {
@@ -392,44 +406,107 @@ body {
border: 1px solid rgba(245, 158, 11, 0.2); border: 1px solid rgba(245, 158, 11, 0.2);
} }
/* ---- Config Panel ---- */ /* ---- Drawer (Config Panel) ---- */
.config-panel { .drawer-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease;
}
.drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
padding: 16px 24px; display: flex;
flex-direction: column;
animation: slideInRight 0.25s ease;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.3);
}
.drawer__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
.config-panel__header { .drawer__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.9rem;
} }
.config-panel__close { .drawer__close {
background: none; background: none;
border: none; border: none;
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 1rem;
padding: 2px 6px; padding: 4px 8px;
border-radius: var(--radius-sm);
transition: all 0.15s;
} }
.config-panel__close:hover { .drawer__close:hover {
background: var(--color-surface-2);
color: var(--color-text); color: var(--color-text);
} }
.drawer__body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* ---- Config Group ---- */
.config-group {
margin-bottom: 20px;
}
.config-group__title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--color-text-muted);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
.config-row { .config-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
padding: 10px 0;
font-size: 0.82rem; font-size: 0.82rem;
color: var(--color-text);
cursor: pointer;
}
.config-row__info {
display: flex;
flex-direction: column;
gap: 2px;
}
.config-row__label {
font-weight: 500;
color: var(--color-text);
}
.config-row__desc {
font-size: 0.72rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
@@ -439,8 +516,15 @@ body {
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 4px 8px; padding: 5px 10px;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer;
}
.config-row input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
} }
/* ---- Toast ---- */ /* ---- Toast ---- */
@@ -559,6 +643,16 @@ body {
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
} }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
/* ---- Scrollbar ---- */ /* ---- Scrollbar ---- */
::-webkit-scrollbar { ::-webkit-scrollbar {

View File

@@ -2,16 +2,29 @@
// CamTalk — 主应用组件Web 端双栏布局) // CamTalk — 主应用组件Web 端双栏布局)
// ============================================================ // ============================================================
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useVisionSession } from "./hooks/useVisionSession"; import { useVisionSession } from "./hooks/useVisionSession";
import { VideoPreview } from "./components/VideoPreview"; import { VideoPreview } from "./components/VideoPreview";
import { ChatPanel } from "./components/ChatPanel"; import { ChatPanel } from "./components/ChatPanel";
import { ConfigPanel } from "./components/ConfigPanel"; import { ConfigPanel } from "./components/ConfigPanel";
import { ToastContainer } from "./components/Toast"; import { ToastContainer } from "./components/Toast";
import { loadTheme, saveTheme } from "./lib/storage";
import type { Theme } from "./types";
import "./App.css"; import "./App.css";
function App() { function App() {
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [theme, setTheme] = useState<Theme>(loadTheme);
// 切换主题时更新 <html> 的 data-theme 属性
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
const handleThemeChange = useCallback((t: Theme) => {
setTheme(t);
saveTheme(t);
}, []);
const { const {
messages, messages,
@@ -54,22 +67,22 @@ function App() {
<span className={`badge badge--${connectionStatus}`}> <span className={`badge badge--${connectionStatus}`}>
{isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"} {isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"}
</span> </span>
{isConnected && ( <button
<button className="btn-icon"
className="btn-icon" onClick={() => setShowConfig((v) => !v)}
onClick={() => setShowConfig((v) => !v)} title="设置"
title="设置" >
>
</button>
</button>
)}
</div> </div>
</header> </header>
{showConfig && ( {showConfig && (
<ConfigPanel <ConfigPanel
config={config} config={config}
theme={theme}
onUpdate={updateConfig} onUpdate={updateConfig}
onThemeChange={handleThemeChange}
onClose={() => setShowConfig(false)} onClose={() => setShowConfig(false)}
/> />
)} )}

View File

@@ -1,55 +1,92 @@
// ============================================================ // ============================================================
// ConfigPanel — 会话配置面板 // ConfigPanel — 右侧抽屉式配置面板
// 职责TTS 开关、detail level 切换、语言选择 // 职责:主题切换、TTS 开关、detail level 切换、语言选择
// ============================================================ // ============================================================
import type { SessionConfig } from "../../types"; import type { SessionConfig, Theme } from "../../types";
interface ConfigPanelProps { interface ConfigPanelProps {
config: SessionConfig; config: SessionConfig;
theme: Theme;
onUpdate: (partial: Partial<SessionConfig>) => void; onUpdate: (partial: Partial<SessionConfig>) => void;
onThemeChange: (theme: Theme) => void;
onClose: () => void; onClose: () => void;
} }
export function ConfigPanel({ config, onUpdate, onClose }: ConfigPanelProps) { export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
return ( return (
<div className="config-panel"> <div className="drawer-overlay" onClick={onClose}>
<div className="config-panel__header"> <div className="drawer" onClick={(e) => e.stopPropagation()}>
<span></span> <div className="drawer__header">
<button className="config-panel__close" onClick={onClose}></button> <span className="drawer__title"></span>
<button className="drawer__close" onClick={onClose}></button>
</div>
<div className="drawer__body">
<div className="config-group">
<div className="config-group__title"></div>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
</div>
<select
value={theme}
onChange={(e) => onThemeChange(e.target.value as Theme)}
>
<option value="dark"></option>
<option value="light"></option>
</select>
</label>
</div>
<div className="config-group">
<div className="config-group__title"></div>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc">AI </span>
</div>
<input
type="checkbox"
checked={config.ttsEnabled}
onChange={(e) => onUpdate({ ttsEnabled: e.target.checked })}
/>
</label>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
</div>
<select
value={config.detailLevel}
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
>
<option value="low"></option>
<option value="high"></option>
</select>
</label>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
</div>
<select
value={config.language}
onChange={(e) => onUpdate({ language: e.target.value })}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
<option value="ja-JP"></option>
</select>
</label>
</div>
</div>
</div> </div>
<label className="config-row">
<span></span>
<input
type="checkbox"
checked={config.ttsEnabled}
onChange={(e) => onUpdate({ ttsEnabled: e.target.checked })}
/>
</label>
<label className="config-row">
<span></span>
<select
value={config.detailLevel}
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
>
<option value="low"></option>
<option value="high"></option>
</select>
</label>
<label className="config-row">
<span></span>
<select
value={config.language}
onChange={(e) => onUpdate({ language: e.target.value })}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
<option value="ja-JP"></option>
</select>
</label>
</div> </div>
); );
} }

View File

@@ -3,9 +3,10 @@
// 职责:会话配置持久化 // 职责:会话配置持久化
// ============================================================ // ============================================================
import type { SessionConfig } from "../types"; import type { SessionConfig, Theme } from "../types";
const CONFIG_KEY = "camtalk:config"; const CONFIG_KEY = "camtalk:config";
const THEME_KEY = "camtalk:theme";
const DEFAULT_CONFIG: SessionConfig = { const DEFAULT_CONFIG: SessionConfig = {
ttsEnabled: true, ttsEnabled: true,
@@ -33,3 +34,19 @@ export function saveConfig(config: SessionConfig): void {
// localStorage 不可用时静默失败 // localStorage 不可用时静默失败
} }
} }
/** 加载主题偏好 */
export function loadTheme(): Theme {
try {
const raw = localStorage.getItem(THEME_KEY);
if (raw === "light" || raw === "dark") return raw;
} catch { /* ignore */ }
return "dark";
}
/** 保存主题偏好 */
export function saveTheme(theme: Theme): void {
try {
localStorage.setItem(THEME_KEY, theme);
} catch { /* ignore */ }
}

View File

@@ -11,6 +11,8 @@ export interface SessionConfig {
language: string; language: string;
} }
export type Theme = "dark" | "light";
export interface Session { export interface Session {
sessionId: string; sessionId: string;
createdAt: string; createdAt: string;