first commit
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal 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
202
README.md
Normal 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
12
index.html
Normal 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
2845
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
16
src/App.tsx
Normal file
16
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/chat/chat-composer.tsx
Normal file
36
src/components/chat/chat-composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/chat/chat-page.tsx
Normal file
48
src/components/chat/chat-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/chat/conversation-list.tsx
Normal file
87
src/components/chat/conversation-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/chat/message-bubble.tsx
Normal file
26
src/components/chat/message-bubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/chat/mock-data.ts
Normal file
56
src/components/chat/mock-data.ts
Normal 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 道题整理成错因标签卡片。"
|
||||
}
|
||||
];
|
||||
16
src/components/chat/types.ts
Normal file
16
src/components/chat/types.ts
Normal 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
91
src/components/icons.tsx
Normal 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" />);
|
||||
39
src/components/layout/app-shell.tsx
Normal file
39
src/components/layout/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/layout/sidebar.tsx
Normal file
120
src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/theme-switcher.tsx
Normal file
31
src/components/layout/theme-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/layout/theme.ts
Normal file
6
src/components/layout/theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ThemeMode = "soft" | "classic";
|
||||
|
||||
export interface ThemeOutletContext {
|
||||
theme: ThemeMode;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
}
|
||||
29
src/components/ui/avatar.tsx
Normal file
29
src/components/ui/avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/badge.tsx
Normal file
29
src/components/ui/badge.tsx
Normal 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 };
|
||||
46
src/components/ui/button.tsx
Normal file
46
src/components/ui/button.tsx
Normal 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 };
|
||||
43
src/components/ui/card.tsx
Normal file
43
src/components/ui/card.tsx
Normal 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 };
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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
254
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
13
src/main.tsx
Normal 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
23
src/pages/review-page.tsx
Normal 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
85
tailwind.config.d.ts
vendored
Normal 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
73
tailwind.config.js
Normal 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
75
tailwind.config.ts
Normal 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
25
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
11
vite.config.js
Normal file
11
vite.config.js
Normal 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
12
vite.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user