登录页切换为官方 input-group 组合并将全站提示从 antd message 统一为 sonner toast,确保通知位置与风格一致。同时补齐相关 shadcn CLI 生成组件及构建配置更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
name: Deploy To Server
|
name: Deploy To Server
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# 暂停自动/手动部署触发;需要恢复时改回 push / workflow_dispatch。
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
disabled:
|
||||||
|
description: "Deployment is temporarily disabled"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-${{ github.ref }}
|
group: deploy-${{ github.ref }}
|
||||||
@@ -11,6 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ false }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy over SSH
|
- name: Deploy over SSH
|
||||||
|
|||||||
940
.yarn/releases/yarn-4.14.1.cjs
vendored
Executable file
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
8
.yarnrc.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||||
@@ -14,8 +14,8 @@ RUN yarn build
|
|||||||
FROM nginx:1.27-alpine AS runner
|
FROM nginx:1.27-alpine AS runner
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
# 覆盖默认站点配置,启用 SPA 回退与缓存策略
|
# 使用官方 Nginx 模板能力,在容器启动时注入 API_UPSTREAM
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
# 仅拷贝构建产物,减小镜像体积
|
# 仅拷贝构建产物,减小镜像体积
|
||||||
COPY --from=builder /app/dist ./
|
COPY --from=builder /app/dist ./
|
||||||
|
|||||||
25
components.json
Normal file
25
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -8,5 +8,10 @@ services:
|
|||||||
image: chat-one-web:latest
|
image: chat-one-web:latest
|
||||||
container_name: chat-one-web
|
container_name: chat-one-web
|
||||||
restart: unless-stopped
|
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:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
|
|||||||
18
nginx.conf
18
nginx.conf
@@ -5,6 +5,24 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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,可长期缓存
|
# Vite build 产物:文件名带 hash,可长期缓存
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -25,12 +25,17 @@
|
|||||||
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
|
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"antd": "^6.3.5",
|
"antd": "^6.3.5",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.45",
|
"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": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -38,7 +43,11 @@
|
|||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -60,5 +69,6 @@
|
|||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.3",
|
||||||
"vite-plugin-pages": "^0.33.3"
|
"vite-plugin-pages": "^0.33.3"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@4.14.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, message } from "antd";
|
import { Button } from "antd";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
import { toast } from "sonner";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "highlight.js/styles/github.css";
|
import "highlight.js/styles/github.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -34,10 +35,10 @@ function MarkdownPreBlock(props: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(codeText);
|
await navigator.clipboard.writeText(codeText);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
message.success("代码已复制");
|
toast.success("代码已复制");
|
||||||
window.setTimeout(() => setCopied(false), 1200);
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
} catch {
|
} catch {
|
||||||
message.error("复制失败");
|
toast.error("复制失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
79
src/components/ui/button-group.tsx
Normal file
79
src/components/ui/button-group.tsx
Normal 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 };
|
||||||
68
src/components/ui/button.tsx
Normal file
68
src/components/ui/button.tsx
Normal 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 };
|
||||||
142
src/components/ui/input-group.tsx
Normal file
142
src/components/ui/input-group.tsx
Normal 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,
|
||||||
|
};
|
||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal 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 };
|
||||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal 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 };
|
||||||
43
src/components/ui/sonner.tsx
Normal file
43
src/components/ui/sonner.tsx
Normal 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 };
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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 };
|
||||||
133
src/index.css
133
src/index.css
@@ -1,4 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@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,
|
*::before,
|
||||||
@@ -62,9 +68,41 @@ body {
|
|||||||
--ds-user-bubble: #e8f3ff;
|
--ds-user-bubble: #e8f3ff;
|
||||||
--ds-user-border: #cce7ff;
|
--ds-user-border: #cce7ff;
|
||||||
--ds-active-item: #eaf3ff;
|
--ds-active-item: #eaf3ff;
|
||||||
--ds-accent: #4a90e2;
|
--ds-accent: #1677ff;
|
||||||
--ds-send: #4a90e2;
|
--ds-send: #1677ff;
|
||||||
--ds-send-hover: #3b7fd0;
|
--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 主题默认白底,保持代码块容器背景一致 */
|
/* 覆盖 highlight.js github 主题默认白底,保持代码块容器背景一致 */
|
||||||
@@ -89,3 +127,92 @@ pre code.hljs {
|
|||||||
display: block;
|
display: block;
|
||||||
transform: translateY(1px);
|
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
6
src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { ConfigProvider, theme } from "antd";
|
import { ConfigProvider, theme } from "antd";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "antd/dist/reset.css";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { MenuProps } from "antd";
|
import type { MenuProps } from "antd";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import logoSrc from "../assets/logo.png";
|
import logoSrc from "../assets/logo.png";
|
||||||
import logoTitleSrc from "../assets/logo-title.png";
|
import logoTitleSrc from "../assets/logo-title.png";
|
||||||
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
|
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
|
||||||
@@ -224,12 +224,12 @@ export default function HomePage() {
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
await logout();
|
await logout();
|
||||||
message.success("已退出登录");
|
toast.success("已退出登录");
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === "app" || key === "settings" || key === "help") {
|
if (key === "app" || key === "settings" || key === "help") {
|
||||||
message.info("功能开发中");
|
toast.info("功能开发中");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ export default function HomePage() {
|
|||||||
setSessions(list);
|
setSessions(list);
|
||||||
return list;
|
return list;
|
||||||
} catch {
|
} catch {
|
||||||
message.error("会话列表加载失败");
|
toast.error("会话列表加载失败");
|
||||||
return [] as ChatSession[];
|
return [] as ChatSession[];
|
||||||
} finally {
|
} finally {
|
||||||
setSessionsLoading(false);
|
setSessionsLoading(false);
|
||||||
@@ -313,7 +313,10 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
||||||
void refreshSessionList();
|
const id = window.setTimeout(() => {
|
||||||
|
void refreshSessionList();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
}, [refreshSessionList]);
|
}, [refreshSessionList]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,7 +334,7 @@ export default function HomePage() {
|
|||||||
const normalized = normalizeSessionMessages(rows);
|
const normalized = normalizeSessionMessages(rows);
|
||||||
setMessages(normalized.length > 0 ? normalized : INITIAL_MESSAGES.map((m) => ({ ...m })));
|
setMessages(normalized.length > 0 ? normalized : INITIAL_MESSAGES.map((m) => ({ ...m })));
|
||||||
} catch {
|
} catch {
|
||||||
message.error("加载会话消息失败");
|
toast.error("加载会话消息失败");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate],
|
[navigate],
|
||||||
@@ -340,13 +343,20 @@ export default function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
||||||
if (!routeSessionId) {
|
if (!routeSessionId) {
|
||||||
setActiveSessionId(null);
|
const id = window.setTimeout(() => {
|
||||||
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
setActiveSessionId(null);
|
||||||
return;
|
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
}
|
}
|
||||||
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
|
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
|
||||||
if (routeSessionId === activeSessionIdRef.current) return;
|
if (routeSessionId === activeSessionIdRef.current) {
|
||||||
void openSession(routeSessionId, { updateUrl: false });
|
return;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
void openSession(routeSessionId, { updateUrl: false });
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
}, [openSession, routeSessionId]);
|
}, [openSession, routeSessionId]);
|
||||||
|
|
||||||
const handleNewSession = async () => {
|
const handleNewSession = async () => {
|
||||||
@@ -385,10 +395,10 @@ export default function HomePage() {
|
|||||||
try {
|
try {
|
||||||
await updateChatSessionTitle(sid, { title: nextTitle });
|
await updateChatSessionTitle(sid, { title: nextTitle });
|
||||||
await refreshSessionList();
|
await refreshSessionList();
|
||||||
message.success("会话已重命名");
|
toast.success("会话已重命名");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : "重命名会话失败";
|
const text = error instanceof Error ? error.message : "重命名会话失败";
|
||||||
message.error(text);
|
toast.error(text);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setSessionActionBusyId(null);
|
setSessionActionBusyId(null);
|
||||||
@@ -422,10 +432,10 @@ export default function HomePage() {
|
|||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
await refreshSessionList();
|
await refreshSessionList();
|
||||||
message.success("会话已删除");
|
toast.success("会话已删除");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : "删除会话失败";
|
const text = error instanceof Error ? error.message : "删除会话失败";
|
||||||
message.error(text);
|
toast.error(text);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setSessionActionBusyId(null);
|
setSessionActionBusyId(null);
|
||||||
@@ -439,18 +449,18 @@ export default function HomePage() {
|
|||||||
const handleCopyUserMessage = useCallback(async (content: string) => {
|
const handleCopyUserMessage = useCallback(async (content: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content);
|
await navigator.clipboard.writeText(content);
|
||||||
message.success("已复制");
|
toast.success("已复制");
|
||||||
} catch {
|
} catch {
|
||||||
message.error("复制失败,请检查浏览器权限");
|
toast.error("复制失败,请检查浏览器权限");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopyAssistantMessage = useCallback(async (content: string) => {
|
const handleCopyAssistantMessage = useCallback(async (content: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content);
|
await navigator.clipboard.writeText(content);
|
||||||
message.success("已复制");
|
toast.success("已复制");
|
||||||
} catch {
|
} catch {
|
||||||
message.error("复制失败,请检查浏览器权限");
|
toast.error("复制失败,请检查浏览器权限");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -473,7 +483,7 @@ export default function HomePage() {
|
|||||||
void refreshSessionList();
|
void refreshSessionList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : "新建会话失败";
|
const text = e instanceof Error ? e.message : "新建会话失败";
|
||||||
message.error(text);
|
toast.error(text);
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
setSessionMutationBusy(false);
|
setSessionMutationBusy(false);
|
||||||
@@ -639,7 +649,7 @@ export default function HomePage() {
|
|||||||
onClick: ({ key, domEvent }) => {
|
onClick: ({ key, domEvent }) => {
|
||||||
domEvent.stopPropagation();
|
domEvent.stopPropagation();
|
||||||
if (key === "rename") void handleRenameSession(s);
|
if (key === "rename") void handleRenameSession(s);
|
||||||
if (key === "pin") message.info("置顶功能开发中");
|
if (key === "pin") toast.info("置顶功能开发中");
|
||||||
if (key === "delete") void handleDeleteSession(s);
|
if (key === "delete") void handleDeleteSession(s);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -892,7 +902,7 @@ export default function HomePage() {
|
|||||||
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
||||||
aria-label="重新生成"
|
aria-label="重新生成"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
message.info("重新生成功能开发中");
|
toast.info("重新生成功能开发中");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -904,7 +914,7 @@ export default function HomePage() {
|
|||||||
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
||||||
aria-label="分享"
|
aria-label="分享"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
message.info("分享功能开发中");
|
toast.info("分享功能开发中");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -930,7 +940,7 @@ export default function HomePage() {
|
|||||||
className="text-neutral-500 hover:bg-black/5!"
|
className="text-neutral-500 hover:bg-black/5!"
|
||||||
aria-label="编辑消息"
|
aria-label="编辑消息"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
message.info("编辑功能开发中");
|
toast.info("编辑功能开发中");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Button, Input, message } from "antd";
|
import { toast } from "sonner";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 logoSrc from "../assets/logo.png";
|
||||||
import logoTitleSrc from "../assets/logo-title.png";
|
import logoTitleSrc from "../assets/logo-title.png";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +27,8 @@ export default function LoginPage() {
|
|||||||
const [sendingSms, setSendingSms] = useState(false);
|
const [sendingSms, setSendingSms] = useState(false);
|
||||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||||
const [loggingIn, setLoggingIn] = useState(false);
|
const [loggingIn, setLoggingIn] = useState(false);
|
||||||
const [msgApi, contextHolder] = message.useMessage();
|
const [phoneError, setPhoneError] = useState("");
|
||||||
|
const [codeError, setCodeError] = useState("");
|
||||||
|
|
||||||
/** 已登录则直接进入首页,避免重复停留在登录页 */
|
/** 已登录则直接进入首页,避免重复停留在登录页 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,12 +54,12 @@ export default function LoginPage() {
|
|||||||
const onLogin = async () => {
|
const onLogin = async () => {
|
||||||
const formattedPhone = formatPhoneForApi(phone);
|
const formattedPhone = formatPhoneForApi(phone);
|
||||||
if (!formattedPhone) {
|
if (!formattedPhone) {
|
||||||
msgApi.warning("请输入正确的手机号");
|
setPhoneError("请输入正确的手机号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trimmedCode = code.trim();
|
const trimmedCode = code.trim();
|
||||||
if (!trimmedCode) {
|
if (!trimmedCode) {
|
||||||
msgApi.warning("请输入验证码");
|
setCodeError("请输入验证码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (loggingIn) return;
|
if (loggingIn) return;
|
||||||
@@ -65,11 +68,11 @@ export default function LoginPage() {
|
|||||||
const data = await smsLogin(formattedPhone, trimmedCode);
|
const data = await smsLogin(formattedPhone, trimmedCode);
|
||||||
persistTokens(data.accessToken, data.refreshToken);
|
persistTokens(data.accessToken, data.refreshToken);
|
||||||
persistUser(data.user);
|
persistUser(data.user);
|
||||||
msgApi.success("登录成功");
|
toast.success("登录成功");
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : "登录失败";
|
const text = error instanceof Error ? error.message : "登录失败";
|
||||||
msgApi.error(text || "登录失败");
|
toast.error(text || "登录失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingIn(false);
|
setLoggingIn(false);
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,7 @@ export default function LoginPage() {
|
|||||||
const onSendSmsCode = async () => {
|
const onSendSmsCode = async () => {
|
||||||
const formattedPhone = formatPhoneForApi(phone);
|
const formattedPhone = formatPhoneForApi(phone);
|
||||||
if (!formattedPhone) {
|
if (!formattedPhone) {
|
||||||
msgApi.warning("请输入正确的手机号");
|
setPhoneError("请输入正确的手机号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sendingSms || smsCooldown > 0) return;
|
if (sendingSms || smsCooldown > 0) return;
|
||||||
@@ -105,21 +108,30 @@ export default function LoginPage() {
|
|||||||
setCode(testCode);
|
setCode(testCode);
|
||||||
}
|
}
|
||||||
setSmsCooldown(SMS_RESEND_COOLDOWN_SEC);
|
setSmsCooldown(SMS_RESEND_COOLDOWN_SEC);
|
||||||
msgApi.success("验证码已发送");
|
toast.success("验证码已发送");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : "验证码发送失败";
|
const text = error instanceof Error ? error.message : "验证码发送失败";
|
||||||
msgApi.error(text || "验证码发送失败");
|
toast.error(text || "验证码发送失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSendingSms(false);
|
setSendingSms(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 手机号、验证码两个输入框共用的外观 class */
|
/** 手机号、验证码输入组共用外观;由 InputGroup 自身承担边框,避免双层边框 */
|
||||||
const loginFieldClass = tw(
|
const loginGroupClass = tw(
|
||||||
"h-[48px] !rounded-[999px] border-gray-200",
|
"h-[48px] rounded-full border-gray-200 bg-gray-50 px-4",
|
||||||
"!bg-gray-50 !text-[14px]",
|
"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 (
|
return (
|
||||||
<main
|
<main
|
||||||
className={tw(
|
className={tw(
|
||||||
@@ -127,7 +139,6 @@ export default function LoginPage() {
|
|||||||
"overflow-hidden bg-white px-4",
|
"overflow-hidden bg-white px-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{contextHolder}
|
|
||||||
<section className="w-[325px] -translate-y-6">
|
<section className="w-[325px] -translate-y-6">
|
||||||
<div className="mb-7 flex items-center justify-center">
|
<div className="mb-7 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
@@ -145,43 +156,77 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Input
|
<div>
|
||||||
size="large"
|
<InputGroup className={loginGroupStateClass(phoneError)}>
|
||||||
placeholder="请输入手机号"
|
<InputGroupInput
|
||||||
value={phone}
|
aria-invalid={!!phoneError}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
placeholder="请输入手机号"
|
||||||
className={loginFieldClass}
|
value={phone}
|
||||||
prefix={<span className="mr-2 text-[14px] text-[#222]">+86</span>}
|
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
|
<div>
|
||||||
size="large"
|
<InputGroup className={loginGroupStateClass(codeError)}>
|
||||||
placeholder="请输入验证码"
|
<InputGroupInput
|
||||||
value={code}
|
aria-invalid={!!codeError}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
placeholder="请输入验证码"
|
||||||
className={loginFieldClass}
|
value={code}
|
||||||
suffix={
|
onChange={(e) => {
|
||||||
<span className="flex items-center gap-2">
|
setCode(e.target.value);
|
||||||
<span className="h-5 w-px bg-gray-200" />
|
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
|
<Button
|
||||||
type="link"
|
type="button"
|
||||||
size="small"
|
variant="link"
|
||||||
loading={sendingSms}
|
size="sm"
|
||||||
disabled={smsCooldown > 0}
|
disabled={smsCooldown > 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void onSendSmsCode();
|
void onSendSmsCode();
|
||||||
}}
|
}}
|
||||||
className={tw(
|
className={tw(
|
||||||
"m-0 h-auto min-w-0 shrink-0 p-0 text-[14px] font-medium",
|
"h-auto min-w-0 shrink-0 p-0 text-[14px] md:text-[14px] font-medium",
|
||||||
"text-[#3b5ff7] hover:text-[#2d4dcc]!",
|
"text-[#1677ff] hover:text-[#0f6eea] hover:no-underline",
|
||||||
"disabled:text-neutral-400! disabled:opacity-100",
|
"disabled:text-neutral-400 disabled:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{smsCooldown > 0 ? `${smsCooldown}s 后重发` : "发送验证码"}
|
{sendingSms
|
||||||
|
? "发送中..."
|
||||||
|
: smsCooldown > 0
|
||||||
|
? `${smsCooldown}s 后重发`
|
||||||
|
: "发送验证码"}
|
||||||
</Button>
|
</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">
|
<p className="block text-[12px] leading-6 text-[#81858c] py-2">
|
||||||
注册登录即代表已阅读并同意我们的{" "}
|
注册登录即代表已阅读并同意我们的{" "}
|
||||||
@@ -196,17 +241,15 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="button"
|
||||||
shape="round"
|
size="lg"
|
||||||
size="large"
|
disabled={loggingIn}
|
||||||
block
|
|
||||||
loading={loggingIn}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void onLogin();
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { defineConfig } from "vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import Pages from "vite-plugin-pages";
|
import Pages from "vite-plugin-pages";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
Pages({
|
Pages({
|
||||||
|
|||||||
Reference in New Issue
Block a user