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

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 };