feat:增加首页登录页面

This commit is contained in:
2026-06-20 17:35:14 +08:00
parent 7288f443f1
commit 2a7d4c74d4
6 changed files with 1744 additions and 4 deletions

View File

@@ -4,8 +4,11 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CamTalk 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
<title>CamTalk AI 视觉对话助手</title>
<meta name="description" content="CamTalk - 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
<title>CamTalk - AI 视觉对话助手</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -11,7 +11,7 @@ import { ChatPanel } from "./components/ChatPanel";
import { ConfigPanel } from "./components/ConfigPanel";
import { SessionSidebar } from "./components/SessionSidebar";
import { ToastContainer } from "./components/Toast";
import { AuthPage } from "./components/AuthPage";
import { LandingPage } from "./components/LandingPage";
import { AuthProvider, useAuth } from "./lib/auth";
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
import { I18nContext, parseLocale, t } from "./lib/i18n";
@@ -232,7 +232,7 @@ function AppContent() {
}
if (!isAuthenticated) {
return <AuthPage />;
return <LandingPage />;
}
return (

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
// ============================================================
// LoginModal — 登录 / 注册模态框
// 职责:在 LandingPage 上弹出的认证表单,复用现有 auth 系统
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../../lib/auth";
import { useI18n } from "../../lib/i18n";
type AuthMode = "login" | "register";
interface LoginModalProps {
initialMode?: AuthMode;
onClose: () => void;
}
export function LoginModal({ initialMode = "login", onClose }: LoginModalProps) {
const { login, register } = useAuth();
const { t } = useI18n();
const [mode, setMode] = useState<AuthMode>(initialMode);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
// 点击遮罩关闭
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose();
},
[onClose]
);
// ESC 关闭
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// 阻止 body 滚动
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, []);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (username.length < 3 || username.length > 64) {
setError(t("auth.error.usernameLength"));
return;
}
if (password.length < 8 || password.length > 72) {
setError(t("auth.error.passwordLength"));
return;
}
setIsSubmitting(true);
const fn = mode === "login" ? login : register;
const result = await fn(username, password);
setIsSubmitting(false);
if (result.error) {
setError(result.error);
}
// 登录成功时 auth 状态更新App 自动切到主界面modal 自然消失
},
[username, password, mode, login, register, t]
);
const switchMode = useCallback(() => {
setMode((m) => (m === "login" ? "register" : "login"));
setError("");
}, []);
return (
<div className="lp-modal-overlay" ref={overlayRef} onClick={handleOverlayClick}>
<div className="lp-modal" role="dialog" aria-modal="true">
<div className="lp-modal__inner">
<button type="button" className="lp-modal__close" onClick={onClose} aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="lp-modal__header">
<div className="lp-modal__logo">CamTalk</div>
<div className="lp-modal__subtitle">{t("auth.subtitle")}</div>
</div>
<form onSubmit={handleSubmit}>
<div className="lp-modal__tabs">
<button
type="button"
className={`lp-modal__tab ${mode === "login" ? "lp-modal__tab--active" : ""}`}
onClick={() => { setMode("login"); setError(""); }}
>
{t("auth.login")}
</button>
<button
type="button"
className={`lp-modal__tab ${mode === "register" ? "lp-modal__tab--active" : ""}`}
onClick={() => { setMode("register"); setError(""); }}
>
{t("auth.register")}
</button>
</div>
<label className="lp-modal__field">
<span className="lp-modal__field-label">{t("auth.username")}</span>
<input
type="text"
className="lp-modal__field-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("auth.username.placeholder")}
autoComplete="username"
autoFocus
/>
</label>
<label className="lp-modal__field">
<span className="lp-modal__field-label">{t("auth.password")}</span>
<input
type="password"
className="lp-modal__field-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("auth.password.placeholder")}
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
</label>
{error && <div className="lp-modal__error">{error}</div>}
<button type="submit" className="lp-modal__submit" disabled={isSubmitting}>
{isSubmitting
? t("auth.submitting")
: mode === "login"
? t("auth.login")
: t("auth.register")}
</button>
</form>
<div className="lp-modal__footer">
{mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")}
<button type="button" className="lp-modal__link" onClick={switchMode}>
{mode === "login" ? t("auth.register") : t("auth.login")}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,390 @@
// ============================================================
// LandingPage — 官网首页(含登录弹窗)
// 职责:未登录用户的落地页,展示产品介绍并提供登录/注册入口
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { LoginModal } from "./LoginModal";
import "./LandingPage.css";
type ModalMode = "login" | "register";
export function LandingPage() {
const [modal, setModal] = useState<{ open: boolean; mode: ModalMode }>({
open: false,
mode: "login",
});
const openModal = useCallback((mode: ModalMode) => {
setModal({ open: true, mode });
}, []);
const closeModal = useCallback(() => {
setModal((prev) => ({ ...prev, open: false }));
}, []);
// ---- Scroll Reveal ----
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("lp-visible");
}
});
},
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
);
document.querySelectorAll(".lp-fade-in").forEach((el) => {
observerRef.current?.observe(el);
});
return () => observerRef.current?.disconnect();
}, []);
return (
<div className="landing-page">
<div className="lp-bg-grid" />
{/* ========== Top Navigation ========== */}
<nav className="lp-nav">
<div className="lp-nav__logo">CamTalk</div>
<div className="lp-nav__links">
<a href="#problem"></a>
<a href="#features"></a>
<a href="#scenes"></a>
<a href="#tech"></a>
</div>
<div className="lp-nav__actions">
<button type="button" className="lp-nav__btn lp-nav__btn--ghost" onClick={() => openModal("login")}>
</button>
<button type="button" className="lp-nav__btn lp-nav__btn--primary" onClick={() => openModal("register")}>
</button>
</div>
</nav>
{/* ========== HERO ========== */}
<section className="lp-hero">
<div className="lp-container lp-hero-content">
<div className="lp-hero-badge">
<span className="lp-hero-badge__dot" />
<span>XEngineers</span>
</div>
<h1>
<span className="lp-gradient">CamTalk</span>
<br />
AI
</h1>
<p className="lp-hero__sub">
AI AI
</p>
<div className="lp-hero-actions">
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
CamTalk
</button>
<div className="lp-hero-actions__links">
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
</a>
<a href="https://github.com/XEngineers/CamTalk" target="_blank" rel="noreferrer">
GitHub
</a>
</div>
</div>
{/* Device Mockup - Double Bezel */}
<div className="lp-hero-visual">
<div className="lp-hero-visual__inner">
<div className="lp-hero-visual__screen">
<div className="lp-screen-left">
<div className="lp-scan-line" />
<div className="lp-camera-ring">
<svg viewBox="0 0 24 24">
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</div>
</div>
<div className="lp-screen-right">
<div className="lp-chat-bubble lp-chat-bubble--user"></div>
<div className="lp-chat-bubble lp-chat-bubble--ai">
x² - 5x + 6 = 0使
<div className="lp-typing">
<span /><span /><span />
</div>
</div>
<div className="lp-chat-bubble lp-chat-bubble--user"></div>
<div className="lp-audio-wave">
<span /><span /><span /><span /><span /><span /><span />
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* ========== PROBLEM ========== */}
<section id="problem">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<div className="lp-section-header__tag"></div>
<h2>AI </h2>
<p> AI </p>
</div>
<div className="lp-problem-grid">
<div className="lp-problem-card lp-fade-in">
<div className="lp-problem-card__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</div>
<h3></h3>
<p>AI </p>
</div>
<div className="lp-problem-card lp-fade-in">
<div className="lp-problem-card__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
</svg>
</div>
<h3></h3>
<p>AI </p>
</div>
<div className="lp-problem-card lp-fade-in">
<div className="lp-problem-card__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
</div>
<h3></h3>
<p> + API $5,000</p>
</div>
</div>
</div>
</section>
{/* ========== SOLUTION ========== */}
<section id="solution">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<h2> + + = AI</h2>
<p>CamTalk </p>
</div>
<div className="lp-flow-wrapper lp-fade-in">
<div className="lp-flow-steps">
{[
{ icon: "📷", label: "摄像头采集" },
{ icon: "🧠", label: "边缘预处理" },
{ icon: "🔌", label: "WebSocket" },
{ icon: "⚡", label: "Eino 编排" },
{ icon: "👁️", label: "视觉理解" },
{ icon: "💬", label: "流式回复" },
{ icon: "🔊", label: "语音输出" },
].map((step, i, arr) => (
<div key={i} style={{ display: "contents" }}>
<div className="lp-flow-step">
<div className="lp-flow-step__node">{step.icon}</div>
<div className="lp-flow-step__label">{step.label}</div>
</div>
{i < arr.length - 1 && <div className="lp-flow-arrow"></div>}
</div>
))}
</div>
</div>
</div>
</section>
{/* ========== FEATURES ========== */}
<section id="features">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<div className="lp-section-header__tag"></div>
<h2></h2>
<p></p>
</div>
<div className="lp-features-grid">
{[
{
num: "01", icon: "⚡",
title: "流式并行推送",
desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。",
},
{
num: "02", icon: "🧠",
title: "声明式 AI 编排",
desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线STT → History → ChatModel → Splitter → TTS类型安全、可扩展、易测试。",
},
{
num: "03", icon: "💰",
title: "端云协同降本",
desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300降幅 90%。",
},
{
num: "04", icon: "🎯",
title: "多场景智能模式",
desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。",
},
{
num: "05", icon: "🏗️",
title: "生产级工程架构",
desc: "三级存储自动降级Memory → Redis → PostgreSQL、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。",
},
].map((f) => (
<div className="lp-feature-card lp-fade-in" key={f.num}>
<div className="lp-feature-card__number">{f.num}</div>
<div className="lp-feature-card__icon">
{f.icon}
</div>
<h3>{f.title}</h3>
<p>{f.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ========== USERS ========== */}
<section id="users">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<h2></h2>
<p> AI </p>
</div>
<div className="lp-users-grid">
{[
{ avatar: "🧑‍🎓", title: "语言学习者", desc: "对着课本或实物,与 AI 英语外教用英语自由对话,实时纠正语法和发音" },
{ avatar: "💼", title: "面试准备者", desc: "开启模拟面试模式AI 面试官通过摄像头观察你的表情与状态,给出针对性反馈" },
{ avatar: "🌍", title: "跨境交流者", desc: "出国旅行时对着外文菜单、路牌实时翻译AI 语音播报翻译结果" },
{ avatar: "👁️", title: "视障人士", desc: "AI 实时描述摄像头画面中的环境、障碍物和文字,提供无障碍信息辅助" },
{ avatar: "🔬", title: "学生 / 教师", desc: "对着题目问「怎么做AI 看到画面后逐步讲解,就像身边有一位私教" },
].map((u) => (
<div className="lp-user-card lp-fade-in" key={u.title}>
<div className="lp-user-card__avatar">{u.avatar}</div>
<h4>{u.title}</h4>
<p>{u.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ========== SCENES ========== */}
<section id="scenes">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<h2></h2>
</div>
<div className="lp-scenes-list">
{[
{ icon: "💬", title: "自由对话", desc: "对着摄像头随意聊天AI 实时理解画面并语音回答", tag: "通用" },
{ icon: "🗣️", title: "英语老师", desc: "AI 外教结合摄像头场景进行英语口语教学,实时纠正语法", tag: "学习" },
{ icon: "🎤", title: "模拟面试", desc: "AI 面试官根据你的回答追问,通过摄像头观察你的表现", tag: "求职" },
{ icon: "⚔️", title: "辩论对手", desc: "AI 反驳你的观点,锻炼你的逻辑思维和表达能力", tag: "思维" },
{ icon: "🌐", title: "同声翻译", desc: "实时识别画面中的外语文字并语音翻译,口语化输出", tag: "工具" },
].map((s) => (
<div className="lp-scene-row lp-fade-in" key={s.title}>
<div className="lp-scene-row__icon">{s.icon}</div>
<div>
<h4>{s.title}</h4>
<div className="lp-scene-row__desc">{s.desc}</div>
</div>
<div className="lp-scene-row__tag">
{s.tag}
</div>
</div>
))}
</div>
</div>
</section>
{/* ========== METRICS ========== */}
<section id="metrics">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<h2></h2>
</div>
<div className="lp-metrics-grid">
{[
{ value: "< 2s", label: "端到端响应延迟" },
{ value: "90%", label: "API 成本降幅" },
{ value: "70%", label: "带宽节省率" },
{ value: "5+3", label: "场景 × 视觉模式" },
].map((m) => (
<div className="lp-metric-card lp-fade-in" key={m.label}>
<div className="lp-metric-card__value">{m.value}</div>
<div className="lp-metric-card__label">{m.label}</div>
</div>
))}
</div>
</div>
</section>
{/* ========== TECH STACK ========== */}
<section id="tech">
<div className="lp-container">
<div className="lp-section-header lp-fade-in">
<h2></h2>
<p> Go AI </p>
</div>
<div className="lp-tech-layers">
{[
{ badge: "前端层", tags: ["React 18", "TypeScript", "Vite", "WebRTC VAD", "Canvas 关键帧检测", "WebSocket", "i18n (中/英/日)"] },
{ badge: "网关层", tags: ["Go + Gin", "gorilla/websocket", "Eino Graph", "JWT 双 Token", "Zap 日志", "Viper 配置"] },
{ badge: "存储层", tags: ["L1 Memory", "L2 Redis", "L3 PostgreSQL", "TieredManager 自动降级"] },
{ badge: "AI 服务", tags: ["qwen3-vl-plus (LLM)", "MiMo ASR (STT)", "MiMo TTS", "Docker Compose"] },
].map((layer) => (
<div className="lp-tech-layer lp-fade-in" key={layer.badge}>
<div>
<div className="lp-tech-layer__badge">
{layer.badge}
</div>
</div>
<div className="lp-tech-layer__tags">
{layer.tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
</div>
))}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className="lp-cta-section">
<div className="lp-container lp-fade-in">
<h2> AI </h2>
<p>CamTalk</p>
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
CamTalk
</button>
<div className="lp-cta-links">
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
</a>
<a href="https://github.com/XEngineers/CamTalk" target="_blank" rel="noreferrer">
GitHub
</a>
</div>
</div>
</section>
{/* ========== Footer ========== */}
<footer className="lp-footer">
<div className="lp-container">
CamTalk © 2026 XEngineers
</div>
</footer>
{/* ========== Login Modal ========== */}
{modal.open && (
<LoginModal initialMode={modal.mode} onClose={closeModal} />
)}
</div>
);
}