first commit

This commit is contained in:
ZhangYonghao
2026-03-26 12:38:19 +08:00
commit af9d343f93
35 changed files with 4477 additions and 0 deletions

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# dependencies
node_modules/
# build outputs
dist/
dist-ssr/
coverage/
*.tsbuildinfo
# env files
.env
.env.*
!.env.example
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# editor / IDE
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
desktop.ini
# local runtime/cache
.cache/
.temp/
tmp/
temp/
# package manager
.pnpm-store/
.npm/
.yarn/
.yarn/cache/
.yarn/unplugged/
.yarn/build-state.yml
.yarn/install-state.gz

202
README.md Normal file
View File

@@ -0,0 +1,202 @@
# AI Chat Frontend
一个用于智能体比赛的前端主入口原型项目。当前阶段聚焦于聊天首页体验,使用 `React + Vite + TypeScript + Tailwind CSS` 构建,提供两套可切换主题:
- `柔和`:更偏 Kawaii Minimal 的浅色柔和风格
- `简洁`:更接近 `shadcn-admin / ChatGPT` 的中性简洁风格
目前未接入真实 API聊天内容使用本地模拟数据渲染。
## 目标
- 作为智能体系统的前端主入口
- 后续承接聊天、多轮对话、知识库、复习流等功能
- 先把布局、主题、交互骨架稳定下来,再接真实后端能力
## 技术栈
- React 19
- Vite 6
- TypeScript
- Tailwind CSS
- React Router
- `class-variance-authority`
- `clsx`
- `tailwind-merge`
## 本地启动
```bash
npm install
npm run dev
```
生产构建:
```bash
npm run build
```
预览构建结果:
```bash
npm run preview
```
## 当前已完成
- 贴边式聊天主界面
- 左侧可折叠侧边栏
- 会话记录列表
- 固定底部输入框
- 聊天消息流展示
- `聊天 / 复习` 两个页面路由
- `柔和 / 简洁` 两套主题一键切换
- 主题切换结果持久化到 `localStorage`
## 当前未完成
- 真实聊天 API 接入
- 流式输出
- 会话创建、切换、删除的真实数据逻辑
- 用户鉴权
- 消息持久化
- 复习页面实际业务功能
## 目录结构
```text
src/
App.tsx
main.tsx
index.css
components/
chat/
chat-page.tsx
chat-composer.tsx
message-bubble.tsx
mock-data.ts
types.ts
layout/
app-shell.tsx
sidebar.tsx
theme-switcher.tsx
theme.ts
ui/
avatar.tsx
badge.tsx
button.tsx
card.tsx
input.tsx
icons.tsx
pages/
review-page.tsx
lib/
utils.ts
```
## 页面说明
### 聊天页
文件:
- `src/components/chat/chat-page.tsx`
- `src/components/chat/chat-composer.tsx`
- `src/components/chat/message-bubble.tsx`
特点:
- 消息区独立滚动
- 输入框固定在底部
- 会话记录放在侧边栏,不单独拆出中栏
### 复习页
文件:
- `src/pages/review-page.tsx`
当前仅做跳转占位,后续可扩展复习计划、知识卡片、错题归档等模块。
## 主题系统
主题状态定义:
- `src/components/layout/theme.ts`
主题切换组件:
- `src/components/layout/theme-switcher.tsx`
主题挂载位置:
- `src/components/layout/app-shell.tsx`
主题变量定义:
- `src/index.css`
说明:
- 主题通过 `data-theme="soft" | "classic"` 挂在最外层容器上
- 视觉差异主要通过 CSS 变量控制
- 页面、侧边栏、聊天气泡、输入框、按钮、头像都会跟随主题变化
协作时建议:
- 新增颜色时优先扩展 CSS 变量,不要在组件里继续堆硬编码颜色
- 如果是主题相关样式,优先落在 `index.css`
- 如果是单个组件结构调整,再改对应 `tsx`
## 数据约定
当前聊天内容来自:
- `src/components/chat/mock-data.ts`
后续接真实接口时建议:
-`mock-data.ts` 替换为独立的 `services/``api/`
- 页面组件只负责展示,不直接拼接请求逻辑
- 消息结构沿用 `src/components/chat/types.ts`,避免重复定义
## 开发约定
### 1. 保持布局原则
- 聊天页采用贴边布局
- 会话记录保留在侧边栏
- 输入框固定底部
- 主聊天区尽量保持留白,不堆无关卡片
### 2. 保持主题策略
- `柔和` 主题:低饱和、轻甜、不杂乱
- `简洁` 主题:接近 ChatGPT / shadcn-admin 的中性灰白
- 不要出现带性别暗示的主题命名
### 3. 样式修改优先级
建议按这个顺序处理:
1. 先看是否能通过 CSS 变量解决
2. 再决定是否需要新增主题类
3. 最后才在组件里写临时颜色类
### 4. 组件复用建议
- 通用样式放 `src/components/ui`
- 页面组合逻辑放 `src/components/chat``src/components/layout`
- 路由页面只做页面级组合,不堆太多视觉细节
## 后续建议任务
推荐下一步按这个顺序推进:
1. 接入真实聊天 API
2. 支持流式回复
3. 接会话列表真实数据
4. 增加会话创建与切换逻辑
5. 补复习页的业务模块
6. 增加异常状态、空状态、加载态
## 说明
如果团队继续迭代这个项目,建议先统一以下内容再开发:
- API 返回格式
- 会话与消息的数据结构
- 主题变量命名规范
- 页面级组件与业务逻辑的拆分边界
这样可以避免后面出现样式、状态、接口三套逻辑交叉污染的问题。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2845
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "ai-chat",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.28.1",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

16
src/App.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { ChatPage } from "@/components/chat/chat-page";
import { AppShell } from "@/components/layout/app-shell";
import { ReviewPage } from "@/pages/review-page";
export default function App() {
return (
<Routes>
<Route element={<AppShell />}>
<Route index element={<ChatPage />} />
<Route path="review" element={<ReviewPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,36 @@
import { PlusIcon, SendIcon } from "@/components/icons";
export function ChatComposer() {
return (
<div className="theme-composer rounded-[24px] border bg-white p-3">
<textarea
rows={3}
placeholder="给 AI 发送消息"
className="min-h-[88px] w-full resize-none border-0 bg-transparent px-2 py-2 text-sm leading-6 text-slate-800 placeholder:text-slate-400"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="button"
className="theme-control flex h-9 w-9 items-center justify-center rounded-full border text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px] hover:text-slate-800"
>
<PlusIcon className="h-4 w-4" />
</button>
<button
type="button"
className="theme-control rounded-full border px-3 py-2 text-xs font-medium text-slate-600 transition duration-300 ease-out hover:-translate-y-[1px]"
>
</button>
</div>
<button
type="button"
className="theme-send flex h-10 w-10 items-center justify-center rounded-full transition duration-300 ease-out hover:-translate-y-[1px]"
>
<SendIcon className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useOutletContext } from "react-router-dom";
import { ChatComposer } from "@/components/chat/chat-composer";
import { messages } from "@/components/chat/mock-data";
import { MessageBubble } from "@/components/chat/message-bubble";
import { ThemeSwitcher } from "@/components/layout/theme-switcher";
import type { ThemeOutletContext } from "@/components/layout/theme";
export function ChatPage() {
const { theme, setTheme } = useOutletContext<ThemeOutletContext>();
return (
<div className="theme-page flex h-full min-h-0 flex-col">
<header className="theme-header flex h-14 items-center justify-between border-b px-5 lg:px-6">
<div className="text-sm font-semibold text-slate-900"></div>
<div className="flex items-center gap-3">
<ThemeSwitcher theme={theme} onChange={setTheme} />
<div className="text-xs text-slate-400"></div>
</div>
</header>
<div className="theme-page relative flex min-h-0 flex-1 flex-col">
<div className="theme-page min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-4 pb-44 pt-8 lg:px-6 lg:pt-10">
{messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-center">
<div>
<h1 className="text-4xl font-semibold tracking-tight text-slate-900"></h1>
</div>
</div>
) : (
<div className="space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
)}
</div>
</div>
<div className="theme-page absolute inset-x-0 bottom-0 px-4 pb-3 pt-8 lg:px-6 lg:pb-5">
<div className="mx-auto max-w-3xl">
<ChatComposer />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { Avatar } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ClockIcon, SearchIcon, StarIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import type { Conversation } from "@/components/chat/types";
interface ConversationListProps {
conversations: Conversation[];
}
export function ConversationList({ conversations }: ConversationListProps) {
return (
<Card className="h-full bg-white/78">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-slate-700"></CardTitle>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<Badge variant="outline">12 </Badge>
</div>
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-11" placeholder="搜索对话、课程、作业题..." />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-[22px] border border-white/90 bg-gradient-to-r from-pastel-cream to-white p-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-pastel-lavender/80 text-violet-700">
<StarIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-semibold text-slate-700"></p>
<p className="truncate text-xs text-muted-foreground">
</p>
</div>
</div>
</div>
<div className="space-y-2">
{conversations.map((conversation, index) => (
<button
key={conversation.id}
type="button"
className={cn(
"hover-lift flex w-full items-start gap-3 rounded-[22px] border px-3 py-3 text-left",
conversation.active
? "border-white bg-white shadow-sm"
: "border-transparent bg-white/55 hover:border-white/80",
)}
>
<Avatar
label={String(index + 1).padStart(2, "0")}
tone={conversation.active ? "pink" : index % 2 === 0 ? "lavender" : "mint"}
className="h-11 w-11 text-xs"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-semibold text-slate-700">{conversation.title}</p>
<span className="text-[11px] text-muted-foreground">{conversation.time}</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{conversation.preview}
</p>
<div className="mt-2 flex items-center gap-2">
<Badge variant={conversation.active ? "secondary" : "outline"}>
{conversation.label}
</Badge>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<ClockIcon className="h-3 w-3" />
</span>
</div>
</div>
</button>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { Message } from "@/components/chat/types";
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isAssistant = message.role === "assistant";
return (
<div className={cn("flex gap-3", !isAssistant && "justify-end")}>
{isAssistant ? <Avatar label="AI" tone="mint" className="h-9 w-9 shrink-0" /> : null}
<div
className={cn(
"max-w-[85%] rounded-[26px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_28px_rgba(245,190,207,0.08)]",
isAssistant ? "theme-assistant-bubble text-slate-800" : "theme-user-bubble text-slate-800",
)}
>
<p className="whitespace-pre-line">{message.content}</p>
</div>
{!isAssistant ? <Avatar label="我" tone="lavender" className="h-9 w-9 shrink-0" /> : null}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Conversation, Message } from "@/components/chat/types";
export const conversations: Conversation[] = [
{
id: "1",
title: "高数错题复盘",
preview: "已整理极限、导数和积分的 6 道错误题。",
label: "数学",
time: "18:20",
active: true
},
{
id: "2",
title: "英语作文润色",
preview: "科技与教育主题作文润色。",
label: "英语",
time: "昨天"
},
{
id: "3",
title: "课堂重点提炼",
preview: "讲义重点整理成速记卡片。",
label: "课堂",
time: "周二"
},
{
id: "4",
title: "竞赛 Demo 头脑风暴",
preview: "讨论前端作为主入口的交互方案。",
label: "项目",
time: "周一"
}
];
export const messages: Message[] = [
{
id: "m1",
role: "assistant",
author: "AI 助手",
content:
"我先帮你把今天的高数复习拆成 3 个小块:\n1. 先抓极限题的共性陷阱。\n2. 再把导数题按公式错误和计算跳步分类。\n3. 最后整理一版 15 分钟冲刺卡片。"
},
{
id: "m2",
role: "user",
author: "你",
content: "可以,先帮我把这 6 道题里最容易重复犯错的点总结一下,语言别太学术。"
},
{
id: "m3",
role: "assistant",
author: "AI 助手",
content:
"最容易重复出现的不是不会做,而是思路知道但写法不稳定:\n\n- 极限题容易在变形时急着约分。\n- 导数题常见问题是链式法则漏掉内部函数求导。\n- 积分题会把换元和凑微分混在一起。\n\n如果你愿意下一步我可以把这 6 道题整理成错因标签卡片。"
}
];

View File

@@ -0,0 +1,16 @@
export interface Conversation {
id: string;
title: string;
preview: string;
label: string;
time: string;
active?: boolean;
}
export interface Message {
id: string;
role: "user" | "assistant";
author: string;
content: string;
hint?: string;
}

91
src/components/icons.tsx Normal file
View File

@@ -0,0 +1,91 @@
import type { ReactNode, SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function createIcon(path: ReactNode) {
return function Icon(props: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{path}
</svg>
);
};
}
export const ChatBubbleIcon = createIcon(
<>
<path d="M7 10h10" />
<path d="M7 14h6" />
<path d="M5 19l-2 2v-4a8 8 0 118 8H5Z" />
</>,
);
export const BookIcon = createIcon(
<>
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 17A2.5 2.5 0 014 14.5v-9A2.5 2.5 0 016.5 3H20v14" />
<path d="M8 7h8" />
</>,
);
export const SparklesIcon = createIcon(
<>
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z" />
<path d="M19 3v4" />
<path d="M21 5h-4" />
<path d="M5 17v4" />
<path d="M7 19H3" />
</>,
);
export const SearchIcon = createIcon(
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>,
);
export const SendIcon = createIcon(
<>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</>,
);
export const PlusIcon = createIcon(
<>
<path d="M12 5v14" />
<path d="M5 12h14" />
</>,
);
export const ClockIcon = createIcon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>,
);
export const PanelLeftIcon = createIcon(
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M9 4v16" />
</>,
);
export const StarIcon = createIcon(
<>
<path d="m12 3 2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 16.5 6.7 19l1-5.8-4.2-4.1 5.9-.9L12 3Z" />
</>,
);
export const ChevronRightIcon = createIcon(<path d="m9 18 6-6-6-6" />);

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { Sidebar } from "@/components/layout/sidebar";
import type { ThemeMode } from "@/components/layout/theme";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "ai-chat-theme";
export function AppShell() {
const [collapsed, setCollapsed] = useState(false);
const [theme, setTheme] = useState<ThemeMode>(() => {
if (typeof window === "undefined") {
return "soft";
}
const savedTheme = window.localStorage.getItem(STORAGE_KEY);
return savedTheme === "classic" ? "classic" : "soft";
});
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
return (
<div data-theme={theme} className="h-screen bg-background">
<div
className={cn(
"grid h-full w-full transition-all duration-200",
collapsed ? "lg:grid-cols-[64px,minmax(0,1fr)]" : "lg:grid-cols-[260px,minmax(0,1fr)]",
)}
>
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} />
<main className="theme-page min-h-0 min-w-0 overflow-hidden">
<Outlet context={{ theme, setTheme }} />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { NavLink } from "react-router-dom";
import { conversations } from "@/components/chat/mock-data";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { BookIcon, ChatBubbleIcon, ClockIcon, PanelLeftIcon, PlusIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
const navItems = [
{ to: "/", label: "聊天", icon: ChatBubbleIcon },
{ to: "/review", label: "复习", icon: BookIcon }
];
interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
}
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
return (
<aside className="theme-sidebar hidden h-full min-h-0 overflow-hidden border-r lg:flex lg:flex-col">
<div className={cn("flex items-center px-3 pt-3", collapsed ? "justify-center" : "justify-between")}>
{!collapsed ? (
<div className="flex items-center gap-2">
<div className="theme-sidebar-card flex h-8 w-8 items-center justify-center rounded-xl text-slate-700">
<ChatBubbleIcon className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-slate-800">AI Chat</span>
</div>
) : null}
<button
type="button"
onClick={onToggle}
className="theme-sidebar-hover flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px] hover:text-slate-800"
>
<PanelLeftIcon className={cn("h-4 w-4 transition-transform", collapsed && "rotate-180")} />
</button>
</div>
<div className="px-3 pt-3">
<Button
variant="secondary"
className={cn(
"theme-sidebar-card h-10 w-full justify-start rounded-2xl border-0 text-slate-800",
collapsed && "justify-center px-0",
)}
>
<PlusIcon className="h-4 w-4" />
{!collapsed ? "新建对话" : null}
</Button>
</div>
<nav className="space-y-1 px-2 pt-4">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"flex h-10 items-center gap-3 rounded-2xl px-3 text-sm text-slate-600 transition duration-300 ease-out",
collapsed && "justify-center px-0",
isActive ? "theme-sidebar-card text-slate-900" : "theme-sidebar-hover hover:-translate-y-[1px] hover:text-slate-900",
)
}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed ? <span>{item.label}</span> : null}
</NavLink>
);
})}
</nav>
{!collapsed ? (
<div className="min-h-0 flex-1 px-2 pb-2 pt-5">
<div className="mb-2 flex items-center justify-between px-2">
<span className="text-xs font-medium text-slate-400"></span>
<Badge variant="outline" className="theme-sidebar-card border-0 text-[11px] text-slate-400">
{conversations.length}
</Badge>
</div>
<div className="h-full overflow-y-auto px-1 pb-2">
<div className="space-y-1">
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
className={cn(
"flex w-full flex-col rounded-2xl px-3 py-2 text-left transition duration-300 ease-out",
conversation.active
? "theme-sidebar-card text-slate-900"
: "theme-sidebar-hover text-slate-600 hover:-translate-y-[1px]",
)}
>
<span className="truncate text-sm font-medium">{conversation.title}</span>
<span className="mt-1 inline-flex items-center gap-1 text-[11px] text-slate-400">
<ClockIcon className="h-3 w-3" />
{conversation.time}
</span>
</button>
))}
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-end justify-center pb-3">
<button
type="button"
className="theme-sidebar-hover flex h-10 w-10 items-center justify-center rounded-2xl text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px]"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
import type { ThemeMode } from "@/components/layout/theme";
interface ThemeSwitcherProps {
theme: ThemeMode;
onChange: (theme: ThemeMode) => void;
className?: string;
}
const options: Array<{ value: ThemeMode; label: string }> = [
{ value: "soft", label: "柔和" },
{ value: "classic", label: "简洁" }
];
export function ThemeSwitcher({ theme, onChange, className }: ThemeSwitcherProps) {
return (
<div className={cn("theme-switch inline-flex items-center rounded-full border p-1", className)}>
{options.map((option) => (
<button
key={option.value}
type="button"
data-active={theme === option.value}
onClick={() => onChange(option.value)}
className="theme-switch-button rounded-full px-3 py-1.5 text-xs font-medium text-slate-500 transition duration-300 ease-out"
>
{option.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export type ThemeMode = "soft" | "classic";
export interface ThemeOutletContext {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
}

View File

@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
interface AvatarProps {
label: string;
tone?: "pink" | "mint" | "lavender" | "cream";
className?: string;
}
const toneMap = {
pink: "avatar-pink",
mint: "avatar-mint",
lavender: "avatar-lavender",
cream: "avatar-cream"
};
export function Avatar({ label, tone = "pink", className }: AvatarProps) {
return (
<div
data-tone={tone}
className={cn(
"theme-avatar inline-flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-bold",
toneMap[tone],
className,
)}
>
{label}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "bg-[#f5e4ff] text-violet-700",
secondary: "bg-[#dbf4e7] text-emerald-700",
outline: "border border-white/90 bg-white/88 text-foreground/75"
}
},
defaultVariants: {
variant: "default"
}
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-300 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] hover:-translate-y-0.5",
{
variants: {
variant: {
default:
"bg-gradient-to-r from-[#ffb8d2] via-[#ffd4e4] to-[#ffe7b8] text-slate-800 shadow-[0_10px_24px_rgba(244,175,200,0.28)] hover:brightness-[1.01]",
secondary: "border border-white/80 bg-white/92 text-foreground shadow-sm hover:bg-white",
ghost: "text-foreground hover:bg-white/70",
outline: "border border-border bg-background/80 hover:bg-white/90"
},
size: {
default: "h-11 px-5",
sm: "h-9 px-4 text-xs",
lg: "h-12 px-6",
icon: "h-11 w-11"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("sticker-card", className)} {...props} />
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-lg font-semibold tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
export { Card, CardContent, CardDescription, CardHeader, CardTitle };

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-full border border-input bg-white/85 px-4 py-2 text-sm text-foreground shadow-sm outline-none transition duration-200 placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };

254
src/index.css Normal file
View File

@@ -0,0 +1,254 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root,
[data-theme="soft"] {
--background: 28 45% 98%;
--foreground: 223 22% 16%;
--card: 0 0% 100%;
--card-foreground: 223 22% 16%;
--popover: 0 0% 100%;
--popover-foreground: 223 22% 16%;
--primary: 336 83% 77%;
--primary-foreground: 0 0% 100%;
--secondary: 30 36% 95%;
--secondary-foreground: 223 20% 22%;
--muted: 26 34% 96%;
--muted-foreground: 220 10% 46%;
--accent: 152 42% 90%;
--accent-foreground: 160 27% 24%;
--border: 24 23% 90%;
--input: 24 23% 90%;
--ring: 336 83% 77%;
--page-bg: #ffffff;
--header-bg: #ffffff;
--header-border: #f0e6df;
--sidebar-bg: #fff8f3;
--sidebar-border: rgba(255, 255, 255, 0.7);
--sidebar-panel: rgba(255, 255, 255, 0.92);
--sidebar-panel-hover: rgba(255, 253, 251, 0.96);
--sidebar-panel-shadow: 0 8px 20px rgba(243, 188, 207, 0.10);
--assistant-bubble-bg: #fffefe;
--assistant-bubble-border: #ecdcd2;
--assistant-bubble-shadow: 0 8px 22px rgba(233, 211, 196, 0.16);
--user-bubble-bg: #faf2f4;
--user-bubble-border: #eadce1;
--user-bubble-shadow: 0 8px 18px rgba(228, 199, 207, 0.12);
--composer-border: rgba(236, 225, 218, 0.9);
--composer-shadow: 0 14px 28px rgba(199, 171, 149, 0.10), 0 6px 12px rgba(15, 23, 42, 0.03);
--control-bg: #fffdfa;
--control-border: #eee3da;
--send-btn-bg: #ffcade;
--send-btn-bg-hover: #ffc1d8;
--send-btn-text: #1f2937;
--send-btn-shadow: 0 10px 20px rgba(245, 182, 206, 0.28);
--toggle-bg: rgba(255, 255, 255, 0.92);
--toggle-border: #efe2d9;
--toggle-active-bg: #ffffff;
--toggle-active-fg: #0f172a;
--toggle-active-shadow: 0 6px 16px rgba(243, 188, 207, 0.18);
--toggle-hover-bg: #fffaf6;
--avatar-shadow: 0 8px 20px rgba(244, 196, 214, 0.18);
}
[data-theme="classic"] {
--background: 220 14% 96%;
--foreground: 222 24% 14%;
--card: 0 0% 100%;
--card-foreground: 222 24% 14%;
--popover: 0 0% 100%;
--popover-foreground: 222 24% 14%;
--primary: 222 47% 11%;
--primary-foreground: 0 0% 100%;
--secondary: 220 16% 96%;
--secondary-foreground: 222 24% 14%;
--muted: 220 16% 96%;
--muted-foreground: 220 9% 46%;
--accent: 220 14% 93%;
--accent-foreground: 222 24% 14%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 222 47% 11%;
--page-bg: #ffffff;
--header-bg: #ffffff;
--header-border: #e5e7eb;
--sidebar-bg: #f8fafc;
--sidebar-border: #e5e7eb;
--sidebar-panel: #ffffff;
--sidebar-panel-hover: #f3f4f6;
--sidebar-panel-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
--assistant-bubble-bg: #ffffff;
--assistant-bubble-border: #e5e7eb;
--assistant-bubble-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
--user-bubble-bg: #f3f4f6;
--user-bubble-border: #e5e7eb;
--user-bubble-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
--composer-border: #e5e7eb;
--composer-shadow: 0 10px 24px rgba(15, 23, 42, 0.08), 0 2px 8px rgba(15, 23, 42, 0.04);
--control-bg: #ffffff;
--control-border: #e5e7eb;
--send-btn-bg: #111827;
--send-btn-bg-hover: #0f172a;
--send-btn-text: #ffffff;
--send-btn-shadow: 0 10px 20px rgba(15, 23, 42, 0.16);
--toggle-bg: #ffffff;
--toggle-border: #e5e7eb;
--toggle-active-bg: #111827;
--toggle-active-fg: #ffffff;
--toggle-active-shadow: none;
--toggle-hover-bg: #f3f4f6;
--avatar-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
* {
@apply border-border;
}
html,
body,
#root {
@apply h-full;
}
body {
@apply bg-white font-sans text-foreground antialiased;
}
button,
input,
textarea {
@apply outline-none;
}
}
@layer components {
.sticker-card {
@apply rounded-[26px] border bg-white backdrop-blur;
border-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 10px 32px rgba(240, 187, 203, 0.12);
}
.soft-panel {
background: var(--sidebar-bg);
}
.hover-lift {
@apply transition duration-300 ease-out hover:-translate-y-[1px] hover:bg-white/90;
}
.theme-page {
background: var(--page-bg);
}
.theme-header {
background: var(--header-bg);
border-color: var(--header-border);
}
.theme-sidebar {
background: var(--sidebar-bg);
border-color: var(--sidebar-border);
}
.theme-sidebar-card {
background: var(--sidebar-panel);
box-shadow: var(--sidebar-panel-shadow);
}
.theme-sidebar-hover:hover {
background: var(--sidebar-panel-hover);
}
.theme-assistant-bubble {
background: var(--assistant-bubble-bg);
border-color: var(--assistant-bubble-border);
box-shadow: var(--assistant-bubble-shadow);
}
.theme-user-bubble {
background: var(--user-bubble-bg);
border-color: var(--user-bubble-border);
box-shadow: var(--user-bubble-shadow);
}
.theme-composer {
border-color: var(--composer-border);
box-shadow: var(--composer-shadow);
}
.theme-control {
background: var(--control-bg);
border-color: var(--control-border);
}
.theme-send {
background: var(--send-btn-bg);
color: var(--send-btn-text);
box-shadow: var(--send-btn-shadow);
}
.theme-send:hover {
background: var(--send-btn-bg-hover);
}
.theme-switch {
background: var(--toggle-bg);
border-color: var(--toggle-border);
}
.theme-switch-button[data-active="true"] {
background: var(--toggle-active-bg);
color: var(--toggle-active-fg);
box-shadow: var(--toggle-active-shadow);
}
.theme-switch-button[data-active="false"]:hover {
background: var(--toggle-hover-bg);
}
.theme-avatar {
box-shadow: var(--avatar-shadow);
}
.avatar-pink {
background: linear-gradient(135deg, #ffd0e1 0%, #ffe6ef 100%);
color: #be185d;
}
.avatar-mint {
background: linear-gradient(135deg, #d8f4e6 0%, #edfdf4 100%);
color: #0f766e;
}
.avatar-lavender {
background: linear-gradient(135deg, #e9ddff 0%, #f8ecff 100%);
color: #7c3aed;
}
.avatar-cream {
background: linear-gradient(135deg, #ffeebf 0%, #fff7de 100%);
color: #b45309;
}
[data-theme="classic"] .avatar-pink {
background: #e5e7eb;
color: #374151;
}
[data-theme="classic"] .avatar-mint {
background: #ecfdf5;
color: #0f766e;
}
[data-theme="classic"] .avatar-lavender {
background: #eef2ff;
color: #4f46e5;
}
[data-theme="classic"] .avatar-cream {
background: #f3f4f6;
color: #374151;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

23
src/pages/review-page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { useOutletContext } from "react-router-dom";
import { ThemeSwitcher } from "@/components/layout/theme-switcher";
import type { ThemeOutletContext } from "@/components/layout/theme";
export function ReviewPage() {
const { theme, setTheme } = useOutletContext<ThemeOutletContext>();
return (
<div className="theme-page flex h-full flex-col">
<header className="theme-header flex h-14 items-center justify-between border-b px-5 lg:px-6">
<div className="text-sm font-semibold text-slate-900"></div>
<ThemeSwitcher theme={theme} onChange={setTheme} />
</header>
<div className="flex flex-1 items-center justify-center px-6">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight text-slate-900"></h1>
<p className="mt-3 text-sm text-slate-400"></p>
</div>
</div>
</div>
);
}

85
tailwind.config.d.ts vendored Normal file
View File

@@ -0,0 +1,85 @@
declare const _default: {
darkMode: ["class"];
content: string[];
theme: {
container: {
center: true;
padding: string;
screens: {
"2xl": string;
};
};
extend: {
colors: {
background: string;
foreground: string;
card: string;
"card-foreground": string;
popover: string;
"popover-foreground": string;
primary: string;
"primary-foreground": string;
secondary: string;
"secondary-foreground": string;
muted: string;
"muted-foreground": string;
accent: string;
"accent-foreground": string;
border: string;
input: string;
ring: string;
pastel: {
peach: string;
pink: string;
cream: string;
mint: string;
lavender: string;
lemon: string;
coral: string;
berry: string;
};
};
borderRadius: {
xl: string;
"2xl": string;
"3xl": string;
};
boxShadow: {
candy: string;
float: string;
};
fontFamily: {
sans: [string, string, string, string];
display: [string, string, string];
};
keyframes: {
bob: {
"0%, 100%": {
transform: string;
};
"50%": {
transform: string;
};
};
pop: {
"0%": {
transform: string;
opacity: string;
};
"100%": {
transform: string;
opacity: string;
};
};
};
animation: {
bob: string;
pop: string;
};
};
};
plugins: {
handler: () => void;
}[];
};
export default _default;

73
tailwind.config.js Normal file
View File

@@ -0,0 +1,73 @@
import animate from "tailwindcss-animate";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: "hsl(var(--card))",
"card-foreground": "hsl(var(--card-foreground))",
popover: "hsl(var(--popover))",
"popover-foreground": "hsl(var(--popover-foreground))",
primary: "hsl(var(--primary))",
"primary-foreground": "hsl(var(--primary-foreground))",
secondary: "hsl(var(--secondary))",
"secondary-foreground": "hsl(var(--secondary-foreground))",
muted: "hsl(var(--muted))",
"muted-foreground": "hsl(var(--muted-foreground))",
accent: "hsl(var(--accent))",
"accent-foreground": "hsl(var(--accent-foreground))",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
pastel: {
peach: "#ffc5b5",
pink: "#ffb8d2",
cream: "#fff8f0",
mint: "#d2f4df",
lavender: "#e5dcff",
lemon: "#fff0ab",
coral: "#ff8b7b",
berry: "#7848ff"
}
},
borderRadius: {
xl: "1.25rem",
"2xl": "1.75rem",
"3xl": "2rem"
},
boxShadow: {
candy: "0 20px 45px rgba(245, 139, 167, 0.14)",
float: "0 18px 35px rgba(103, 78, 167, 0.12)"
},
fontFamily: {
sans: ["Aptos", "Trebuchet MS", "Segoe UI", "sans-serif"],
display: ["Comic Sans MS", "Trebuchet MS", "sans-serif"]
},
keyframes: {
bob: {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-4px)" }
},
pop: {
"0%": { transform: "scale(0.98)", opacity: "0.8" },
"100%": { transform: "scale(1)", opacity: "1" }
}
},
animation: {
bob: "bob 4.8s ease-in-out infinite",
pop: "pop 260ms ease-out"
}
}
},
plugins: [animate]
};

75
tailwind.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: "hsl(var(--card))",
"card-foreground": "hsl(var(--card-foreground))",
popover: "hsl(var(--popover))",
"popover-foreground": "hsl(var(--popover-foreground))",
primary: "hsl(var(--primary))",
"primary-foreground": "hsl(var(--primary-foreground))",
secondary: "hsl(var(--secondary))",
"secondary-foreground": "hsl(var(--secondary-foreground))",
muted: "hsl(var(--muted))",
"muted-foreground": "hsl(var(--muted-foreground))",
accent: "hsl(var(--accent))",
"accent-foreground": "hsl(var(--accent-foreground))",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
pastel: {
peach: "#ffc5b5",
pink: "#ffb8d2",
cream: "#fff8f0",
mint: "#d2f4df",
lavender: "#e5dcff",
lemon: "#fff0ab",
coral: "#ff8b7b",
berry: "#7848ff"
}
},
borderRadius: {
xl: "1.25rem",
"2xl": "1.75rem",
"3xl": "2rem"
},
boxShadow: {
candy: "0 20px 45px rgba(245, 139, 167, 0.14)",
float: "0 18px 35px rgba(103, 78, 167, 0.12)"
},
fontFamily: {
sans: ["Aptos", "Trebuchet MS", "Segoe UI", "sans-serif"],
display: ["Comic Sans MS", "Trebuchet MS", "sans-serif"]
},
keyframes: {
bob: {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-4px)" }
},
pop: {
"0%": { transform: "scale(0.98)", opacity: "0.8" },
"100%": { transform: "scale(1)", opacity: "1" }
}
},
animation: {
bob: "bob 4.8s ease-in-out infinite",
pop: "pop 260ms ease-out"
}
}
},
plugins: [animate]
} satisfies Config;

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}

2
vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});