feat(ui): 统一登录与会话页的 shadcn 交互组件
All checks were successful
CI / build (push) Successful in 2m31s

登录页切换为官方 input-group 组合并将全站提示从 antd message 统一为 sonner toast,确保通知位置与风格一致。同时补齐相关 shadcn CLI 生成组件及构建配置更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 18:33:57 +08:00
parent 2f901c5488
commit 8018438f42
25 changed files with 11931 additions and 4746 deletions

View File

@@ -1,9 +1,13 @@
name: Deploy To Server
on:
push:
branches: [main, master]
# 暂停自动/手动部署触发;需要恢复时改回 push / workflow_dispatch。
workflow_dispatch:
inputs:
disabled:
description: "Deployment is temporarily disabled"
required: false
default: "true"
concurrency:
group: deploy-${{ github.ref }}
@@ -11,6 +15,7 @@ concurrency:
jobs:
deploy:
if: ${{ false }}
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH

940
.yarn/releases/yarn-4.14.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

8
.yarnrc.yml Normal file
View File

@@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.14.1.cjs

View File

@@ -14,8 +14,8 @@ RUN yarn build
FROM nginx:1.27-alpine AS runner
WORKDIR /usr/share/nginx/html
# 覆盖默认站点配置,启用 SPA 回退与缓存策略
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 使用官方 Nginx 模板能力,在容器启动时注入 API_UPSTREAM
COPY nginx.conf /etc/nginx/templates/default.conf.template
# 仅拷贝构建产物,减小镜像体积
COPY --from=builder /app/dist ./

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -8,5 +8,10 @@ services:
image: chat-one-web:latest
container_name: chat-one-web
restart: unless-stopped
environment:
# 后端接口地址;如果后端也在 compose 网络中,改成类似 http://chat-one-api:3000
API_UPSTREAM: http://host.docker.internal:3000
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "80:80"

View File

@@ -5,6 +5,24 @@ server {
root /usr/share/nginx/html;
index index.html;
# 后端接口反向代理Docker 启动时由 API_UPSTREAM 注入例如http://host.docker.internal:3000
location /api/ {
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
}
# Vite build 产物:文件名带 hash可长期缓存
location /assets/ {
expires 1y;

View File

@@ -25,12 +25,17 @@
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@microsoft/fetch-event-source": "^2.0.1",
"antd": "^6.3.5",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"lucide-react": "^1.9.0",
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
@@ -38,7 +43,11 @@
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -60,5 +69,6 @@
"typescript-eslint": "^8.58.0",
"vite": "^8.0.3",
"vite-plugin-pages": "^0.33.3"
}
},
"packageManager": "yarn@4.14.1"
}

View File

@@ -1,11 +1,12 @@
import { useState } from "react";
import { Button, message } from "antd";
import { Button } from "antd";
import { Check, Copy } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import { toast } from "sonner";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import type { ReactNode } from "react";
@@ -34,10 +35,10 @@ function MarkdownPreBlock(props: { children: ReactNode }) {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
message.success("代码已复制");
toast.success("代码已复制");
window.setTimeout(() => setCopied(false), 1200);
} catch {
message.error("复制失败");
toast.error("复制失败");
}
};

View File

@@ -0,0 +1,79 @@
/* eslint-disable react-refresh/only-export-components */
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };

View File

@@ -0,0 +1,68 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,142 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,43 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -1,4 +1,10 @@
@import "tailwindcss";
@import "antd/dist/reset.css" layer(base);
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
*,
*::before,
@@ -62,9 +68,41 @@ body {
--ds-user-bubble: #e8f3ff;
--ds-user-border: #cce7ff;
--ds-active-item: #eaf3ff;
--ds-accent: #4a90e2;
--ds-send: #4a90e2;
--ds-send-hover: #3b7fd0;
--ds-accent: #1677ff;
--ds-send: #1677ff;
--ds-send-hover: #0f6eea;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #1677ff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: #1677ff;
--chart-1: #1677ff;
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #1677ff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
/* 覆盖 highlight.js github 主题默认白底,保持代码块容器背景一致 */
@@ -89,3 +127,92 @@ pre code.hljs {
display: block;
transform: translateY(1px);
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #1677ff;
--primary-foreground: #ffffff;
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: #1677ff;
--chart-1: #1677ff;
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

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

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

View File

@@ -2,7 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { ConfigProvider, theme } from "antd";
import { BrowserRouter } from "react-router-dom";
import "antd/dist/reset.css";
import { Toaster } from "@/components/ui/sonner";
import "./index.css";
import App from "./App";
@@ -51,6 +51,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>
<Toaster position="top-center" />
</ConfigProvider>
</React.StrictMode>,
);

View File

@@ -34,10 +34,10 @@ import {
Select,
Tooltip,
Typography,
message,
} from "antd";
import type { MenuProps } from "antd";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import logoSrc from "../assets/logo.png";
import logoTitleSrc from "../assets/logo-title.png";
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
@@ -224,12 +224,12 @@ export default function HomePage() {
abortRef.current?.abort();
setIsSending(false);
await logout();
message.success("已退出登录");
toast.success("已退出登录");
navigate("/login", { replace: true });
return;
}
if (key === "app" || key === "settings" || key === "help") {
message.info("功能开发中");
toast.info("功能开发中");
}
};
@@ -304,7 +304,7 @@ export default function HomePage() {
setSessions(list);
return list;
} catch {
message.error("会话列表加载失败");
toast.error("会话列表加载失败");
return [] as ChatSession[];
} finally {
setSessionsLoading(false);
@@ -313,7 +313,10 @@ export default function HomePage() {
useEffect(() => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
void refreshSessionList();
const id = window.setTimeout(() => {
void refreshSessionList();
}, 0);
return () => window.clearTimeout(id);
}, [refreshSessionList]);
/**
@@ -331,7 +334,7 @@ export default function HomePage() {
const normalized = normalizeSessionMessages(rows);
setMessages(normalized.length > 0 ? normalized : INITIAL_MESSAGES.map((m) => ({ ...m })));
} catch {
message.error("加载会话消息失败");
toast.error("加载会话消息失败");
}
},
[navigate],
@@ -340,13 +343,20 @@ export default function HomePage() {
useEffect(() => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
if (!routeSessionId) {
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
return;
const id = window.setTimeout(() => {
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
}, 0);
return () => window.clearTimeout(id);
}
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
if (routeSessionId === activeSessionIdRef.current) return;
void openSession(routeSessionId, { updateUrl: false });
if (routeSessionId === activeSessionIdRef.current) {
return;
}
const id = window.setTimeout(() => {
void openSession(routeSessionId, { updateUrl: false });
}, 0);
return () => window.clearTimeout(id);
}, [openSession, routeSessionId]);
const handleNewSession = async () => {
@@ -385,10 +395,10 @@ export default function HomePage() {
try {
await updateChatSessionTitle(sid, { title: nextTitle });
await refreshSessionList();
message.success("会话已重命名");
toast.success("会话已重命名");
} catch (error) {
const text = error instanceof Error ? error.message : "重命名会话失败";
message.error(text);
toast.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
@@ -422,10 +432,10 @@ export default function HomePage() {
navigate("/");
}
await refreshSessionList();
message.success("会话已删除");
toast.success("会话已删除");
} catch (error) {
const text = error instanceof Error ? error.message : "删除会话失败";
message.error(text);
toast.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
@@ -439,18 +449,18 @@ export default function HomePage() {
const handleCopyUserMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
toast.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
toast.error("复制失败,请检查浏览器权限");
}
}, []);
const handleCopyAssistantMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
toast.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
toast.error("复制失败,请检查浏览器权限");
}
}, []);
@@ -473,7 +483,7 @@ export default function HomePage() {
void refreshSessionList();
} catch (e) {
const text = e instanceof Error ? e.message : "新建会话失败";
message.error(text);
toast.error(text);
return;
} finally {
setSessionMutationBusy(false);
@@ -639,7 +649,7 @@ export default function HomePage() {
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (key === "rename") void handleRenameSession(s);
if (key === "pin") message.info("置顶功能开发中");
if (key === "pin") toast.info("置顶功能开发中");
if (key === "delete") void handleDeleteSession(s);
},
};
@@ -892,7 +902,7 @@ export default function HomePage() {
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="重新生成"
onClick={() => {
message.info("重新生成功能开发中");
toast.info("重新生成功能开发中");
}}
/>
</Tooltip>
@@ -904,7 +914,7 @@ export default function HomePage() {
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="分享"
onClick={() => {
message.info("分享功能开发中");
toast.info("分享功能开发中");
}}
/>
</Tooltip>
@@ -930,7 +940,7 @@ export default function HomePage() {
className="text-neutral-500 hover:bg-black/5!"
aria-label="编辑消息"
onClick={() => {
message.info("编辑功能开发中");
toast.info("编辑功能开发中");
}}
/>
</div>

View File

@@ -1,6 +1,8 @@
import { Button, Input, message } from "antd";
import { toast } from "sonner";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import logoSrc from "../assets/logo.png";
import logoTitleSrc from "../assets/logo-title.png";
import {
@@ -25,7 +27,8 @@ export default function LoginPage() {
const [sendingSms, setSendingSms] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [loggingIn, setLoggingIn] = useState(false);
const [msgApi, contextHolder] = message.useMessage();
const [phoneError, setPhoneError] = useState("");
const [codeError, setCodeError] = useState("");
/** 已登录则直接进入首页,避免重复停留在登录页 */
useEffect(() => {
@@ -51,12 +54,12 @@ export default function LoginPage() {
const onLogin = async () => {
const formattedPhone = formatPhoneForApi(phone);
if (!formattedPhone) {
msgApi.warning("请输入正确的手机号");
setPhoneError("请输入正确的手机号");
return;
}
const trimmedCode = code.trim();
if (!trimmedCode) {
msgApi.warning("请输入验证码");
setCodeError("请输入验证码");
return;
}
if (loggingIn) return;
@@ -65,11 +68,11 @@ export default function LoginPage() {
const data = await smsLogin(formattedPhone, trimmedCode);
persistTokens(data.accessToken, data.refreshToken);
persistUser(data.user);
msgApi.success("登录成功");
toast.success("登录成功");
navigate("/", { replace: true });
} catch (error) {
const text = error instanceof Error ? error.message : "登录失败";
msgApi.error(text || "登录失败");
toast.error(text || "登录失败");
} finally {
setLoggingIn(false);
}
@@ -94,7 +97,7 @@ export default function LoginPage() {
const onSendSmsCode = async () => {
const formattedPhone = formatPhoneForApi(phone);
if (!formattedPhone) {
msgApi.warning("请输入正确的手机号");
setPhoneError("请输入正确的手机号");
return;
}
if (sendingSms || smsCooldown > 0) return;
@@ -105,21 +108,30 @@ export default function LoginPage() {
setCode(testCode);
}
setSmsCooldown(SMS_RESEND_COOLDOWN_SEC);
msgApi.success("验证码已发送");
toast.success("验证码已发送");
} catch (error) {
const text = error instanceof Error ? error.message : "验证码发送失败";
msgApi.error(text || "验证码发送失败");
toast.error(text || "验证码发送失败");
} finally {
setSendingSms(false);
}
};
/** 手机号、验证码两个输入共用外观 class */
const loginFieldClass = tw(
"h-[48px] !rounded-[999px] border-gray-200",
"!bg-gray-50 !text-[14px]",
/** 手机号、验证码输入共用外观;由 InputGroup 自身承担边框,避免双层边框 */
const loginGroupClass = tw(
"h-[48px] rounded-full border-gray-200 bg-gray-50 px-4",
"transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-gray-300",
"has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-[#1677ff]/15",
);
const loginGroupStateClass = (error: string) =>
tw(
loginGroupClass,
error
? "border-red-400 has-[[data-slot=input-group-control]:focus-visible]:border-red-400 has-[[data-slot=input-group-control]:focus-visible]:ring-red-500/15"
: "",
);
return (
<main
className={tw(
@@ -127,7 +139,6 @@ export default function LoginPage() {
"overflow-hidden bg-white px-4",
)}
>
{contextHolder}
<section className="w-[325px] -translate-y-6">
<div className="mb-7 flex items-center justify-center">
<img
@@ -145,43 +156,77 @@ export default function LoginPage() {
</div>
<div className="space-y-5">
<Input
size="large"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={loginFieldClass}
prefix={<span className="mr-2 text-[14px] text-[#222]">+86</span>}
/>
<div>
<InputGroup className={loginGroupStateClass(phoneError)}>
<InputGroupInput
aria-invalid={!!phoneError}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
if (phoneError) setPhoneError("");
}}
className="h-auto bg-transparent px-0 py-0 text-[14px] md:text-[14px] shadow-none focus-visible:ring-0"
/>
<InputGroupAddon align="inline-start" className="mr-2 text-[14px] text-[#222]">
+86
</InputGroupAddon>
</InputGroup>
<p
className={tw(
"mt-1 min-h-4 px-4 text-xs text-red-500",
phoneError ? "visible" : "invisible",
)}
>
{phoneError || " "}
</p>
</div>
<Input
size="large"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
className={loginFieldClass}
suffix={
<span className="flex items-center gap-2">
<span className="h-5 w-px bg-gray-200" />
<div>
<InputGroup className={loginGroupStateClass(codeError)}>
<InputGroupInput
aria-invalid={!!codeError}
placeholder="请输入验证码"
value={code}
onChange={(e) => {
setCode(e.target.value);
if (codeError) setCodeError("");
}}
className="h-auto bg-transparent px-0 py-0 text-[14px] md:text-[14px] shadow-none focus-visible:ring-0"
/>
<InputGroupAddon align="inline-end" className="pr-0">
<span className="mx-2 h-5 w-px bg-gray-200" />
<Button
type="link"
size="small"
loading={sendingSms}
type="button"
variant="link"
size="sm"
disabled={smsCooldown > 0}
onClick={() => {
void onSendSmsCode();
}}
className={tw(
"m-0 h-auto min-w-0 shrink-0 p-0 text-[14px] font-medium",
"text-[#3b5ff7] hover:text-[#2d4dcc]!",
"disabled:text-neutral-400! disabled:opacity-100",
"h-auto min-w-0 shrink-0 p-0 text-[14px] md:text-[14px] font-medium",
"text-[#1677ff] hover:text-[#0f6eea] hover:no-underline",
"disabled:text-neutral-400 disabled:opacity-100",
)}
>
{smsCooldown > 0 ? `${smsCooldown}s 后重发` : "发送验证码"}
{sendingSms
? "发送中..."
: smsCooldown > 0
? `${smsCooldown}s 后重发`
: "发送验证码"}
</Button>
</span>
}
/>
</InputGroupAddon>
</InputGroup>
<p
className={tw(
"mt-1 min-h-4 px-4 text-xs text-red-500",
codeError ? "visible" : "invisible",
)}
>
{codeError || " "}
</p>
</div>
<p className="block text-[12px] leading-6 text-[#81858c] py-2">
{" "}
@@ -196,17 +241,15 @@ export default function LoginPage() {
</p>
<Button
type="primary"
shape="round"
size="large"
block
loading={loggingIn}
type="button"
size="lg"
disabled={loggingIn}
onClick={() => {
void onLogin();
}}
className="mt-1 h-[42px] rounded-full border-0"
className="mt-1 h-[46px] w-full rounded-full text-[14px] md:text-[14px]"
>
{loggingIn ? "登录中..." : "登录"}
</Button>
</div>
</section>

View File

@@ -12,6 +12,11 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"strict": true
},
"include": ["src"]

View File

@@ -1,4 +1,11 @@
{
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -2,8 +2,14 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import Pages from "vite-plugin-pages";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
react(),
Pages({

14893
yarn.lock

File diff suppressed because it is too large Load Diff