#978 safe-align: 固化 frontend-v2 空白页修复真相源

- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git)
- vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice)
- useApi.ts: asArray select 防御非裸数组返回值
- LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice)
- 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md
- 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
This commit is contained in:
QihuAI Design committed 2026-08-23 09:36:35 +08:00
1 parent 552a962123
commit 8f57956904
36 files changed
+7436

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/query-client';
import { Layout } from '@/components/layout/Layout';
import { LoginPage } from '@/pages/front/LoginPage';
import { LandingPage } from '@/pages/front/LandingPage';
import { ChatPage } from '@/pages/front/ChatPage';
import { KnowledgePage } from '@/pages/front/KnowledgePage';
import { ImBotHub } from '@/pages/front/ImBotHub';
import { AppServices } from '@/pages/front/AppServices';
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Layout />}>
<Route index element={<LandingPage />} />
<Route path="chat" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="apps" element={<AppServices />} />
{/* Placeholder routes */}
<Route path="market" element={<PlaceholderPage title="技能市场" />} />
<Route path="agents" element={<PlaceholderPage title="Agent 智能体" />} />
<Route path="mcp" element={<PlaceholderPage title="MCP 工具" />} />
<Route path="workflows" element={<PlaceholderPage title="工作流" />} />
<Route path="rules" element={<PlaceholderPage title="规则库" />} />
<Route path="channels" element={<PlaceholderPage title="渠道" />} />
<Route path="dashboard" element={<PlaceholderPage title="驾驶舱" />} />
<Route path="profile" element={<PlaceholderPage title="个人中心" />} />
<Route path="notifications" element={<PlaceholderPage title="消息通知" />} />
<Route path="evolution" element={<PlaceholderPage title="自进化" />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
function PlaceholderPage({ title }: { title: string }) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-center">
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
<p className="text-muted-foreground">页面开发中...</p>
</div>
</div>
);
}
export default App;
+247
View File
@@ -0,0 +1,247 @@
const BASE_URL = import.meta.env.VITE_API_URL || '/app/api';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> || {}),
};
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
// ─── Auth ───────────────────────────────────────────────────────────────────────
export const authApi = {
login: (data: { username: string; password: string }) =>
request<{ access_token: string; token_type: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
register: (data: { username: string; email: string; password: string; code: string }) =>
request<{ access_token: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
}),
me: () => request<{ id: string; username: string; email: string; avatar_url: string }>('/auth/me'),
logout: () => request('/auth/logout', { method: 'POST' }),
sendCode: (data: { email: string }) =>
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
};
// ─── Chat ────────────────────────────────────────────────────────────────────────
export interface Conversation {
id: string;
title: string;
model: string;
created_at: string;
updated_at: string;
message_count: number;
}
export interface ChatMessage {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
model: string;
created_at: string;
}
export const chatApi = {
listConversations: () => request<Conversation[]>('/chat/conversations'),
createConversation: (data: { title?: string; model?: string }) =>
request<Conversation>('/chat/conversations', {
method: 'POST',
body: JSON.stringify(data),
}),
deleteConversation: (id: string) =>
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
getMessages: (convId: string) =>
request<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
sendMessage: (convId: string, data: { content: string; model?: string }) =>
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
method: 'POST',
body: JSON.stringify(data),
}),
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
const token = localStorage.getItem('auth_token');
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(data),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6));
if (data.content) yield data.content;
} catch {}
}
}
},
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
};
// ─── Knowledge ───────────────────────────────────────────────────────────────────
export interface KnowledgeBase {
id: string;
name: string;
description: string;
document_count: number;
status: 'active' | 'building' | 'error';
created_at: string;
updated_at: string;
}
export const knowledgeApi = {
listBases: () => request<KnowledgeBase[]>('/knowledge/bases'),
createBase: (data: { name: string; description?: string }) =>
request<KnowledgeBase>('/knowledge/bases', {
method: 'POST',
body: JSON.stringify(data),
}),
deleteBase: (id: string) =>
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
search: (baseId: string, query: string) =>
request<{ results: { content: string; score: number; source: string }[] }>(
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
),
getDocuments: (baseId: string) =>
request<{ id: string; name: string; status: string; size: number }[]>(
`/knowledge/bases/${baseId}/documents`
),
};
// ─── Market / Apps ──────────────────────────────────────────────────────────────
export interface MarketApp {
id: string;
name: string;
description: string;
icon: string;
category: string;
status: 'enabled' | 'disabled';
type: 'standalone' | 'embedded';
downloads: number;
rating: number;
}
export const marketApi = {
listApps: () => request<MarketApp[]>('/market/apps'),
getMyApps: () => request<MarketApp[]>('/market/my-apps'),
installApp: (appId: string) =>
request(`/market/apps/${appId}/install`, { method: 'POST' }),
uninstallApp: (appId: string) =>
request(`/market/apps/${appId}/uninstall`, { method: 'DELETE' }),
getAds: () => request<{ id: string; title: string; image_url: string; url: string }[]>('/market/ads'),
};
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
export interface ImBotStatus {
status: 'connected' | 'disconnected' | 'expired';
ilink_bot_id: string;
connected_at: string;
last_active: string;
message_count: number;
}
export const imBotApi = {
getStatus: () => request<ImBotStatus>('/im-bot/status'),
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
checkQRStatus: (ticket: string) =>
request<{ status: string }>('/im-bot/qrcode/status', {
method: 'POST',
body: JSON.stringify({ ticket }),
}),
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
};
// ─── Profile ─────────────────────────────────────────────────────────────────────
export interface UserProfile {
id: string;
username: string;
email: string;
avatar_url: string;
balance: number;
membership: string;
}
export const profileApi = {
get: () => request<UserProfile>('/profile/'),
update: (data: Partial<UserProfile>) =>
request<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(data) }),
getBalance: () => request<{ balance: number; total_used: number }>('/profile/balance'),
};
// ─── Dashboard / Stats ──────────────────────────────────────────────────────────
export const dashboardApi = {
getStats: () =>
request<{
active_users: number;
today_conversations: number;
skill_count: number;
revenue: number;
user_change: string;
conv_change: string;
}>('/token/usage'),
getHotSkills: () =>
request<{ name: string; downloads: string }[]>('/market/apps'),
};
@@ -0,0 +1,40 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Bell, Search } from 'lucide-react';
interface HeaderProps {
title?: string;
showSearch?: boolean;
}
export function Header({ title, showSearch = false }: HeaderProps) {
return (
<header className="h-16 border-b border-border bg-white dark:bg-gray-950 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
{title && <h1 className="text-lg font-semibold text-foreground">{title}</h1>}
</div>
<div className="flex items-center gap-4">
{showSearch && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="搜索..."
className="h-9 w-[240px] rounded-lg border border-border bg-muted px-9 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<button className="relative p-2 rounded-lg hover:bg-muted text-muted-foreground transition-colors">
<Bell size={20} />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</button>
<Avatar className="h-8 w-8">
<AvatarImage src="/avatar.png" alt="用户" />
<AvatarFallback>U</AvatarFallback>
</Avatar>
</div>
</header>
);
}
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function Layout() {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="ml-[240px]">
<Header />
<main className="p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,136 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
import {
MessageSquare,
BookOpen,
Zap,
Bot,
Puzzle,
Workflow,
Shield,
Radio,
AppWindow,
LayoutDashboard,
User,
Bell,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
interface NavItem {
path: string;
label: string;
icon: React.ReactNode;
}
interface NavGroup {
title: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'AI 能力',
items: [
{ path: '/chat', label: 'AI 聊天', icon: <MessageSquare size={20} /> },
{ path: '/knowledge', label: '知识库', icon: <BookOpen size={20} /> },
{ path: '/market', label: '技能市场', icon: <Zap size={20} /> },
{ path: '/agents', label: 'Agent', icon: <Bot size={20} /> },
{ path: '/mcp', label: 'MCP 工具', icon: <Puzzle size={20} /> },
],
},
{
title: 'AI 资源',
items: [
{ path: '/workflows', label: '工作流', icon: <Workflow size={20} /> },
{ path: '/rules', label: '规则库', icon: <Shield size={20} /> },
{ path: '/channels', label: '渠道', icon: <Radio size={20} /> },
{ path: '/apps', label: '应用中心', icon: <AppWindow size={20} /> },
],
},
];
const bottomItems: NavItem[] = [
{ path: '/dashboard', label: '驾驶舱', icon: <LayoutDashboard size={20} /> },
{ path: '/profile', label: '个人中心', icon: <User size={20} /> },
{ path: '/notifications', label: '消息通知', icon: <Bell size={20} /> },
];
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={cn(
'fixed left-0 top-0 z-40 h-screen border-r border-border bg-white dark:bg-gray-950 transition-all duration-300 flex flex-col',
collapsed ? 'w-[68px]' : 'w-[240px]'
)}>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-white font-bold text-sm">七</span>
</div>
<span className="text-lg font-bold text-foreground">七虎AI</span>
</div>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4 px-2">
{navGroups.map((group, i) => (
<div key={i} className="mb-4">
{!collapsed && (
<div className="px-3 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{group.title}
</div>
)}
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</div>
))}
</div>
{/* Bottom Navigation */}
<div className="border-t border-border py-2 px-2">
{bottomItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</div>
</aside>
);
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)} {...props} />
)
);
Avatar.displayName = 'Avatar';
const AvatarImage = React.forwardRef<HTMLImageElement, React.ImgHTMLAttributes<HTMLImageElement>>(
({ className, ...props }, ref) => (
<img ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
)
);
AvatarImage.displayName = 'AvatarImage';
const AvatarFallback = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)} {...props} />
)
);
AvatarFallback.displayName = 'AvatarFallback';
export { Avatar, AvatarImage, AvatarFallback };
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Badge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' }>(
({ className, variant = 'default', ...props }, ref) => {
const variants = {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
success: 'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
warning: 'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
destructive: 'border-transparent bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
return (
<div
ref={ref}
className={cn(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
variants[variant],
className
)}
{...props}
/>
);
}
);
Badge.displayName = 'Badge';
export { Badge };
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
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-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+46
View File
@@ -0,0 +1,46 @@
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('rounded-xl border border-border bg-card text-card-foreground shadow-sm', 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<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div 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';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className)}
{...props}
/>
)
);
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn('fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl', className)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
)
);
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
);
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,37 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<React.ComponentRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>>(
({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(
({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
);
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>>(
({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', className)}
{...props}
/>
)
);
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>>(
({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
)
);
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>>(
({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
{...props}
/>
)
);
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<React.ComponentRef<typeof TooltipPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>>(
({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn('z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className)}
{...props}
/>
)
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+203
View File
@@ -0,0 +1,203 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
authApi,
chatApi,
knowledgeApi,
marketApi,
imBotApi,
profileApi,
dashboardApi,
} from '@/api/client';
// ─── Auth ───────────────────────────────────────────────────────────────────────
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) => authApi.login(data),
onSuccess: (res) => {
localStorage.setItem('auth_token', res.access_token);
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
},
});
}
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'me'],
queryFn: authApi.me,
retry: false,
enabled: !!localStorage.getItem('auth_token'),
});
}
// ─── Chat ────────────────────────────────────────────────────────────────────────
export function useConversations() {
return useQuery({
queryKey: ['chat', 'conversations'],
queryFn: chatApi.listConversations,
});
}
export function useMessages(convId: string | null) {
return useQuery({
queryKey: ['chat', 'messages', convId],
queryFn: () => chatApi.getMessages(convId!),
enabled: !!convId,
});
}
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { title?: string; model?: string }) => chatApi.createConversation(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
});
}
export function useSendMessage(convId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { content: string; model?: string }) => chatApi.sendMessage(convId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'messages', convId] }),
});
}
export function useDeleteConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => chatApi.deleteConversation(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
});
}
export function useModels() {
return useQuery({
queryKey: ['chat', 'models'],
queryFn: chatApi.getModels,
});
}
// ─── Knowledge ───────────────────────────────────────────────────────────────────
export function useKnowledgeBases() {
return useQuery({
queryKey: ['knowledge', 'bases'],
queryFn: knowledgeApi.listBases,
});
}
export function useCreateKnowledgeBase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string }) => knowledgeApi.createBase(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
});
}
export function useDeleteKnowledgeBase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => knowledgeApi.deleteBase(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
});
}
export function useKnowledgeSearch(baseId: string | null, query: string) {
return useQuery({
queryKey: ['knowledge', 'search', baseId, query],
queryFn: () => knowledgeApi.search(baseId!, query),
enabled: !!baseId && query.length > 0,
});
}
// ─── Market / Apps ──────────────────────────────────────────────────────────────
// Backend list endpoints may return a bare array, `{data:[...]}`, or an error
// object. Coerce to a real array so consumers can always call .slice/.map/.filter.
function asArray<T>(v: unknown): T[] {
if (Array.isArray(v)) return v as T[];
if (v && typeof v === 'object' && Array.isArray((v as any).data)) return (v as any).data;
if (v && typeof v === 'object' && Array.isArray((v as any).items)) return (v as any).items;
return [];
}
export function useMarketApps() {
return useQuery({
queryKey: ['market', 'apps'],
queryFn: marketApi.listApps,
select: (d) => asArray(d),
});
}
export function useMyApps() {
return useQuery({
queryKey: ['market', 'my-apps'],
queryFn: marketApi.getMyApps,
select: (d) => asArray(d),
});
}
export function useInstallApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (appId: string) => marketApi.installApp(appId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
},
});
}
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
export function useImBotStatus() {
return useQuery({
queryKey: ['im-bot', 'status'],
queryFn: imBotApi.getStatus,
refetchInterval: 30000, // poll every 30s
});
}
export function useImBotQRCode() {
return useQuery({
queryKey: ['im-bot', 'qrcode'],
queryFn: imBotApi.getQRCode,
enabled: false, // only on demand
});
}
export function useDisconnectBot() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => imBotApi.disconnect(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['im-bot', 'status'] }),
});
}
// ─── Profile ─────────────────────────────────────────────────────────────────────
export function useProfile() {
return useQuery({
queryKey: ['profile'],
queryFn: profileApi.get,
});
}
export function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) => profileApi.update(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
});
}
// ─── Dashboard ───────────────────────────────────────────────────────────────────
export function useDashboardStats() {
return useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: dashboardApi.getStats,
});
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import './styles/globals.css';
function Home() {
return <div style={{ padding: '20px' }}><h1>首页</h1></div>;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/globals.css';
function App() {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>七虎AI</h1>
<p>测试页面 - 如果看到这个说明 React 正常工作</p>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,110 @@
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
AppWindow,
Plus,
ExternalLink,
Settings,
Play,
Globe,
Monitor,
Loader2,
} from 'lucide-react';
import { useMyApps } from '@/hooks/useApi';
import type { MarketApp } from '@/api/client';
export function AppServices() {
const { data: rawApps, isLoading } = useMyApps();
const apps: MarketApp[] = Array.isArray(rawApps) ? rawApps : (rawApps?.data ?? rawApps?.items ?? []);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
<p className="text-sm text-muted-foreground mt-1">管理第三方 AI 应用</p>
</div>
<Button className="gap-2">
<Plus size={16} /> 创建应用
</Button>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : apps.length === 0 ? (
<div className="col-span-full text-center py-12 text-muted-foreground">
<AppWindow size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-1">暂无应用</p>
<p className="text-sm">去应用市场浏览并安装应用</p>
</div>
) : (
apps.map((app: MarketApp) => (
<Card key={app.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center">
{app.type === 'standalone' ? (
<Globe size={20} className="text-blue-600" />
) : (
<Monitor size={20} className="text-purple-600" />
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={app.status === 'enabled' ? 'success' : 'secondary'}>
{app.status === 'enabled' ? '已启用' : '已停用'}
</Badge>
<Badge variant="outline">
{app.type === 'standalone' ? '独立应用' : '嵌入式'}
</Badge>
</div>
</div>
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">{app.description}</p>
<Separator className="mb-4" />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1">
<Settings size={14} /> 配置
</Button>
<Button
size="sm"
className="gap-1"
variant={app.status === 'enabled' ? 'default' : 'outline'}
>
{app.status === 'enabled' ? (
<><ExternalLink size={14} /> 打开</>
) : (
<><Play size={14} /> 启用</>
)}
</Button>
</div>
</CardContent>
</Card>
))
)}
{/* Create New */}
<Card className="border-dashed hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[220px]">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<Plus size={20} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">创建新应用</p>
</CardContent>
</Card>
</motion.div>
</div>
);
}
@@ -0,0 +1,270 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import {
Send,
Sparkles,
Plus,
Search,
Settings,
MessageSquare,
Copy,
ThumbsUp,
ThumbsDown,
RotateCcw,
Trash2,
Loader2,
} from 'lucide-react';
import {
useConversations,
useMessages,
useCreateConversation,
useSendMessage,
useDeleteConversation,
} from '@/hooks/useApi';
import type { Conversation, ChatMessage } from '@/api/client';
export function ChatPage() {
const [selectedConv, setSelectedConv] = useState<string | null>(null);
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const [streamingContent, setStreamingContent] = useState('');
// API hooks
const { data: conversations = [], isLoading: convsLoading } = useConversations();
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
const createConv = useCreateConversation();
const sendMessage = useSendMessage(selectedConv || '');
const deleteConv = useDeleteConversation();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
const handleNewChat = async () => {
try {
const conv = await createConv.mutateAsync({ title: '新对话' });
setSelectedConv(conv.id);
} catch {}
};
const handleSend = async () => {
if (!input.trim() || !selectedConv) return;
const content = input;
setInput('');
setStreamingContent('');
try {
// Try streaming first
const stream = chatApi.streamMessage(selectedConv, { content });
let accumulated = '';
for await (const chunk of stream) {
accumulated += chunk;
setStreamingContent(accumulated);
}
setStreamingContent('');
// Refresh messages
} catch {
// Fallback to regular send
await sendMessage.mutateAsync({ content });
}
};
const handleDelete = async (id: string) => {
try {
await deleteConv.mutateAsync(id);
if (selectedConv === id) setSelectedConv(null);
} catch {}
};
return (
<div className="flex h-[calc(100vh-4rem)] gap-4">
{/* Conversation List */}
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="搜索对话..."
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<Button
className="mx-3 mt-3 gap-2"
variant="outline"
onClick={handleNewChat}
disabled={createConv.isPending}
>
<Plus size={16} /> 新建对话
</Button>
<ScrollArea className="flex-1 px-2 py-2">
<div className="space-y-1">
{convsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : (
conversations.map((conv: Conversation) => (
<div key={conv.id} className="group relative">
<button
onClick={() => setSelectedConv(conv.id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
selectedConv === conv.id
? 'bg-primary/10 text-primary'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<div className="flex items-center gap-2">
<MessageSquare size={14} className="shrink-0" />
<span className="text-sm truncate flex-1">{conv.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 ml-5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{conv.model || 'Auto'}
</Badge>
<span className="text-[10px] text-muted-foreground">
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
</span>
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
>
<Trash2 size={12} className="text-destructive" />
</button>
</div>
))
)}
</div>
</ScrollArea>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
{/* Messages */}
<ScrollArea className="flex-1 p-6">
{!selectedConv ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
<p className="text-sm text-muted-foreground max-w-md">
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
</p>
<Button className="mt-4 gap-2" onClick={handleNewChat}>
<Plus size={16} /> 新建对话
</Button>
</div>
) : msgsLoading ? (
<div className="h-full flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
<div className="space-y-6">
{messages.map((msg: ChatMessage) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
}`}>
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<Copy size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsUp size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsDown size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<RotateCcw size={14} className="text-muted-foreground" />
</button>
</div>
)}
</div>
</motion.div>
))}
{/* Streaming indicator */}
{streamingContent && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
<div className="flex items-center gap-1 mt-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
)}
</ScrollArea>
{/* Input Area */}
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
className="flex-1"
disabled={!selectedConv || sendMessage.isPending}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
className="gap-2"
>
{sendMessage.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Send size={16} />
)}
</Button>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles size={12} /> Auto(自动匹配)
</div>
<button className="p-1 hover:bg-muted rounded transition-colors">
<Settings size={16} className="text-muted-foreground" />
</button>
</div>
</div>
</div>
</div>
);
}
// Import for streaming
import { chatApi } from '@/api/client';
@@ -0,0 +1,163 @@
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Wifi,
WifiOff,
Plus,
MessageSquare,
Settings,
QrCode,
ExternalLink,
Loader2,
AlertTriangle,
} from 'lucide-react';
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
const otherChannels = [
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
];
export function ImBotHub() {
const { data: wechatStatus, isLoading } = useImBotStatus();
const disconnectBot = useDisconnectBot();
const handleDisconnect = async () => {
if (!confirm('确定断开微信连接?')) return;
try {
await disconnectBot.mutateAsync();
} catch {}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
</div>
<Button className="gap-2">
<Plus size={16} /> 添加渠道
</Button>
</div>
{/* WeChat Bot - Real Data */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
💬
</div>
<div>
<h3 className="font-semibold text-foreground">个人微信</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{wechatStatus?.ilink_bot_id || '未配置'}
</p>
</div>
</div>
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
{isLoading ? (
<Loader2 size={12} className="animate-spin" />
) : wechatStatus?.status === 'connected' ? (
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
) : wechatStatus?.status === 'expired' ? (
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
) : (
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
)}
</Badge>
</div>
{wechatStatus?.status === 'connected' && (
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
<span className="flex items-center gap-1">
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
</span>
<span>
连接于 {new Date(wechatStatus.connected_at).toLocaleString('zh-CN')}
</span>
</div>
)}
{wechatStatus?.status === 'expired' && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
会话已过期,请重新扫码连接
</div>
)}
<Separator className="mb-4" />
<div className="flex items-center gap-2">
{wechatStatus?.status === 'connected' ? (
<>
<Button variant="outline" size="sm" className="gap-1">
<Settings size={14} /> 管理
</Button>
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink size={14} /> 查看对话
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 text-destructive hover:text-destructive"
onClick={handleDisconnect}
disabled={disconnectBot.isPending}
>
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
断开连接
</Button>
</>
) : (
<Button size="sm" className="gap-1">
<QrCode size={14} /> 扫码绑定
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
{/* Other Channels - Mock */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
>
{otherChannels.map((channel) => (
<Card key={channel.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
{channel.icon}
</div>
<div>
<h3 className="font-semibold text-foreground">{channel.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
</div>
</div>
<Badge variant="secondary">
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
</Badge>
</div>
<Separator className="mb-4" />
<div className="flex items-center gap-2">
<Button size="sm" className="gap-1">
<QrCode size={14} /> 扫码绑定
</Button>
</div>
</CardContent>
</Card>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,201 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Search,
Plus,
Upload,
FileText,
Database,
RefreshCw,
Trash2,
Loader2,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
useKnowledgeBases,
useCreateKnowledgeBase,
useDeleteKnowledgeBase,
} from '@/hooks/useApi';
import type { KnowledgeBase } from '@/api/client';
export function KnowledgePage() {
const [searchQuery, setSearchQuery] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const { data: bases = [], isLoading } = useKnowledgeBases();
const createBase = useCreateKnowledgeBase();
const deleteBase = useDeleteKnowledgeBase();
const handleCreate = async () => {
if (!newName.trim()) return;
try {
await createBase.mutateAsync({ name: newName, description: newDesc });
setShowCreateDialog(false);
setNewName('');
setNewDesc('');
} catch {}
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此知识库?')) return;
try {
await deleteBase.mutateAsync(id);
} catch {}
};
return (
<div className="space-y-6">
{/* Search Bar */}
<Card>
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="在所有知识库中检索问题..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<p className="text-xs text-muted-foreground mt-2">
支持跨库检索 · 向量相似度排序 · 来源溯源
</p>
</CardContent>
</Card>
{/* Tabs */}
<Tabs defaultValue="all">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="all">全部知识库</TabsTrigger>
<TabsTrigger value="recent">最近使用</TabsTrigger>
</TabsList>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus size={16} /> 新建知识库
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建知识库</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>名称</Label>
<Input
placeholder="知识库名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>描述</Label>
<Input
placeholder="简要描述(可选)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleCreate}
disabled={!newName.trim() || createBase.isPending}
>
{createBase.isPending ? (
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
) : '创建'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<TabsContent value="all" className="mt-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
bases.map((kb: KnowledgeBase) => (
<Card key={kb.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center">
<Database size={20} className="text-blue-600" />
</div>
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
</Badge>
</div>
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<FileText size={14} /> {kb.document_count} 文档
</span>
</div>
{kb.description && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
)}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<span className="text-xs text-muted-foreground">
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
</span>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
<Upload size={14} className="text-muted-foreground" />
</button>
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
<RefreshCw size={14} className="text-muted-foreground" />
</button>
<button
onClick={() => handleDelete(kb.id)}
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
disabled={deleteBase.isPending}
>
<Trash2 size={14} className="text-destructive" />
</button>
</div>
</div>
</CardContent>
</Card>
))
)}
{/* Add New Card */}
<Card
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setShowCreateDialog(true)}
>
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<Plus size={20} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
</CardContent>
</Card>
</motion.div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,259 @@
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import {
MessageSquare,
Zap,
Bot,
Puzzle,
BookOpen,
AppWindow,
TrendingUp,
ArrowRight,
ChevronRight,
Loader2,
} from 'lucide-react';
import { useDashboardStats, useMarketApps, useCurrentUser } from '@/hooks/useApi';
const quickEntries = [
{ icon: <MessageSquare size={24} />, label: 'AI 聊天', desc: 'SSE 流式对话,支持代码块和 Markdown', path: '/chat', color: 'bg-blue-50 text-blue-600' },
{ icon: <Zap size={24} />, label: '技能市场', desc: '浏览、导入、发布 Skills 技能', path: '/market', color: 'bg-amber-50 text-amber-600' },
{ icon: <Bot size={24} />, label: 'Agent 智能体', desc: 'Dify / N8N / Coze 多源导入', path: '/agents', color: 'bg-green-50 text-green-600' },
{ icon: <Puzzle size={24} />, label: 'MCP 工具', desc: 'Model Context Protocol 工具集成', path: '/mcp', color: 'bg-purple-50 text-purple-600' },
{ icon: <BookOpen size={24} />, label: '知识库', desc: '文档管理 + RAG 向量检索', path: '/knowledge', color: 'bg-cyan-50 text-cyan-600' },
{ icon: <AppWindow size={24} />, label: '应用中心', desc: '第三方 AI 应用接入与管理', path: '/apps', color: 'bg-pink-50 text-pink-600' },
];
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
export function LandingPage() {
const navigate = useNavigate();
const { data: stats, isLoading: statsLoading } = useDashboardStats();
const { data: rawApps, isLoading: appsLoading } = useMarketApps();
// Defensive: backend may wrap the list or return an error object; coerce to array
// so `.slice()` / `.filter()` below never crash the whole page.
const apps: any[] = Array.isArray(rawApps) ? rawApps : (rawApps?.data ?? rawApps?.items ?? []);
const { data: user } = useCurrentUser();
const displayStats = [
{ label: '活跃用户', value: stats?.active_users?.toLocaleString() || '---', change: stats?.user_change, trend: 'up' as const },
{ label: '今日对话', value: stats?.today_conversations?.toLocaleString() || '---', change: stats?.conv_change, trend: 'up' as const },
{ label: '技能总数', value: stats?.skill_count?.toString() || '---', change: undefined, trend: 'up' as const },
{ label: '交易金额', value: stats?.revenue ? `¥${stats.revenue.toLocaleString()}` : '---', change: undefined, trend: 'up' as const },
];
// Derive hot skills and agents from apps
const hotSkills = apps.slice(0, 5).map((app: { id: string; name: string; downloads: number }) => ({
name: app.name,
downloads: app.downloads >= 1000 ? `${(app.downloads / 1000).toFixed(1)}k` : app.downloads.toString(),
}));
const hotAgents = apps.filter((a: { category: string }) => a.category === 'agent').slice(0, 5).map((app: { id: string; name: string; downloads: number }) => ({
name: app.name,
downloads: app.downloads >= 1000 ? `${(app.downloads / 1000).toFixed(1)}k` : app.downloads.toString(),
}));
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-white/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center shadow-sm">
<span className="text-white font-bold text-base">七</span>
</div>
<div>
<span className="text-lg font-bold text-foreground">七虎AI</span>
<span className="text-xs text-muted-foreground ml-2 hidden sm:inline">AI PaaS 平台</span>
</div>
</div>
<nav className="flex items-center gap-6">
<button onClick={() => navigate('/market')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">技能市场</button>
<button onClick={() => navigate('/agents')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">Agent</button>
<button onClick={() => navigate('/chat')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">AI 聊天</button>
<button
onClick={() => user ? navigate('/chat') : navigate('/login')}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
{user ? user.username : '登录 / 注册'}
</button>
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
{/* Stats */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"
>
{displayStats.map((stat, i) => (
<motion.div key={i} variants={item}>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{stat.label}</p>
<div className="flex items-baseline gap-2 mt-1">
{statsLoading ? (
<Loader2 size={20} className="animate-spin text-primary" />
) : (
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
)}
{stat.change && (
<span className="text-xs text-green-600 font-medium flex items-center gap-0.5">
<TrendingUp size={12} /> {stat.change}
</span>
)}
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
{/* Quick Entry */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4">快捷入口</h2>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
>
{quickEntries.map((entry, i) => (
<motion.div key={i} variants={item}>
<Card
className="hover:shadow-md hover:border-primary/20 transition-all cursor-pointer group"
onClick={() => navigate(entry.path)}
>
<CardContent className="p-5 flex items-start gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 ${entry.color}`}>
{entry.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
{entry.label}
</h3>
<ArrowRight size={16} className="text-muted-foreground group-hover:text-primary transition-colors opacity-0 group-hover:opacity-100" />
</div>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{entry.desc}</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</div>
{/* Hot Rankings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<motion.div variants={container} initial="hidden" animate="show">
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground flex items-center gap-2">
<Zap size={18} className="text-amber-500" /> 热门技能
</h3>
<button
onClick={() => navigate('/market')}
className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1"
>
更多 <ChevronRight size={12} />
</button>
</div>
<div className="space-y-3">
{appsLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : hotSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">暂无数据</p>
) : (
hotSkills.map((skill, i) => (
<motion.div
key={i}
variants={item}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div className="flex items-center gap-3">
<span className="w-5 h-5 rounded-md bg-primary/10 text-primary text-xs font-bold flex items-center justify-center">
{i + 1}
</span>
<span className="text-sm text-foreground">{skill.name}</span>
</div>
<span className="text-xs text-muted-foreground">{skill.downloads}</span>
</motion.div>
))
)}
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={container} initial="hidden" animate="show">
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground flex items-center gap-2">
<Bot size={18} className="text-green-500" /> 热门 Agent
</h3>
<button
onClick={() => navigate('/agents')}
className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1"
>
更多 <ChevronRight size={12} />
</button>
</div>
<div className="space-y-3">
{appsLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : hotAgents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">暂无数据</p>
) : (
hotAgents.map((agent, i) => (
<motion.div
key={i}
variants={item}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div className="flex items-center gap-3">
<span className="w-5 h-5 rounded-md bg-green-50 text-green-600 text-xs font-bold flex items-center justify-center">
{i + 1}
</span>
<span className="text-sm text-foreground">{agent.name}</span>
</div>
<span className="text-xs text-muted-foreground">{agent.downloads}</span>
</motion.div>
))
)}
</div>
</CardContent>
</Card>
</motion.div>
</div>
</main>
{/* Footer */}
<footer className="border-t border-border mt-12 py-8">
<div className="max-w-7xl mx-auto px-6 text-center text-sm text-muted-foreground">
© 2026 七虎AI · 养龙虾与 AI 能力探索
</div>
</footer>
</div>
);
}
@@ -0,0 +1,139 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Mail, Smartphone, ArrowRight, Loader2 } from 'lucide-react';
import { useLogin } from '@/hooks/useApi';
export function LoginPage() {
const navigate = useNavigate();
const loginMutation = useLogin();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [error, setError] = useState('');
const handleLogin = async (e: FormEvent) => {
e.preventDefault();
setError('');
try {
await loginMutation.mutateAsync({ username: email, password });
navigate('/chat');
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center mx-auto mb-4 shadow-lg">
<span className="text-white font-bold text-2xl">七</span>
</div>
<h1 className="text-2xl font-bold text-foreground">七虎AI</h1>
<p className="text-muted-foreground mt-1">养龙虾与 AI 能力探索</p>
</div>
<Card className="shadow-xl border-0">
<CardContent className="p-6">
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
{error}
</div>
)}
<Tabs defaultValue="email" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="email" className="gap-2">
<Mail size={16} /> 邮箱登录
</TabsTrigger>
<TabsTrigger value="phone" className="gap-2">
<Smartphone size={16} /> 手机登录
</TabsTrigger>
</TabsList>
<TabsContent value="email">
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">邮箱地址</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full gap-2" disabled={loginMutation.isPending}>
{loginMutation.isPending ? (
<><Loader2 size={16} className="animate-spin" /> 登录中...</>
) : (
<>登录 <ArrowRight size={16} /></>
)}
</Button>
</form>
</TabsContent>
<TabsContent value="phone">
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">手机号码</Label>
<Input
id="phone"
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="code">验证码</Label>
<div className="flex gap-2">
<Input
id="code"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<Button variant="outline" type="button" className="whitespace-nowrap">
发送验证码
</Button>
</div>
</div>
<Button type="submit" className="w-full gap-2">
登录 <ArrowRight size={16} />
</Button>
</form>
</TabsContent>
</Tabs>
<div className="mt-6 text-center text-sm text-muted-foreground">
<a href="#" className="text-primary hover:underline">忘记密码?</a>
<span className="mx-2">·</span>
<span>还没有账号?</span>
<a href="#" className="text-primary hover:underline ml-1">立即注册</a>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+395
View File
@@ -0,0 +1,395 @@
/* ===== 七虎AI 统一设计系统 ===== */
/* 桥接旧版 CSS 变量 + Tailwind shadcn/ui 主题 */
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import "tailwindcss";
@source "../";
/* ═══════════════════════════════════════════════════════════════════════════ */
/* Tailwind CSS 主题 (shadcn/ui 兼容) */
/* ═══════════════════════════════════════════════════════════════════════════ */
@theme inline {
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 9999px;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: #219B5E;
--color-warning: #E8922C;
--color-info: #3B82F6;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* Tailwind 主题色值 (CSS 变量) */
/* ═══════════════════════════════════════════════════════════════════════════ */
:root {
/* --- Tailwind shadcn/ui 变量 --- */
--background: #F8F9FB;
--foreground: #101214;
--card: #FFFFFF;
--card-foreground: #101214;
--primary: #5B5FED;
--primary-foreground: #FFFFFF;
--secondary: #F1F3F7;
--secondary-foreground: #101214;
--muted: #F1F3F7;
--muted-foreground: #8B919E;
--accent: #5B5FED;
--accent-foreground: #FFFFFF;
--destructive: #D9383A;
--destructive-foreground: #FFFFFF;
--border: #E3E6EB;
--input: #E3E6EB;
--ring: #5B5FED;
/* --- 旧版变量 (兼容旧页面) --- */
--color-bg-primary: #F8F9FB;
--color-bg-secondary: #FFFFFF;
--color-bg-tertiary: #F1F3F7;
--color-bg-hover: #EBEEF3;
--color-bg-active: #E3E7F0;
--color-text-primary: #101214;
--color-text-secondary: #545A65;
--color-text-tertiary: #8B919E;
--color-text-disabled: #BFC4CE;
--color-text-inverse: #FFFFFF;
--color-accent: #5B5FED;
--color-accent-hover: #4A4ECC;
--color-accent-light: #EEEDFF;
--color-accent-subtle: #F6F5FF;
--color-success: #219B5E;
--color-success-light: #E6F7EE;
--color-warning: #E8922C;
--color-warning-light: #FEF4E8;
--color-error: #D9383A;
--color-error-light: #FDEBEB;
--color-info: #3B82F6;
--color-info-light: #EBF2FE;
--color-border: #E3E6EB;
--color-border-light: #EEF0F4;
--color-border-hover: #CDD2DA;
--color-sidebar-bg: #F5F6F9;
--color-sidebar-hover: #EAECF2;
--color-sidebar-active: #E1E3F0;
/* --- 阴影 --- */
--shadow-xs: 0 1px 2px rgba(0,0,0,0.04);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
--shadow-xl: 0 20px 48px rgba(0,0,0,0.1), 0 4px 12px rgba(0,0,0,0.06);
/* --- 间距 --- */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 20px;
--space-2xl: 24px;
--space-3xl: 32px;
--space-4xl: 40px;
--space-5xl: 48px;
/* --- 字号 --- */
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
/* --- 布局 --- */
--sidebar-width: 240px;
--sidebar-collapsed: 64px;
--header-height: 56px;
/* --- 过渡 --- */
--transition-fast: 120ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* 暗色模式 */
/* ═══════════════════════════════════════════════════════════════════════════ */
.dark, [data-theme="dark"] {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #818cf8;
--primary-foreground: #0f172a;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #818cf8;
--accent-foreground: #0f172a;
--destructive: #f87171;
--destructive-foreground: #0f172a;
--border: #334155;
--input: #334155;
--ring: #818cf8;
/* 旧版变量暗色 */
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-tertiary: #1e293b;
--color-bg-hover: #334155;
--color-bg-active: #475569;
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5e1;
--color-text-tertiary: #94a3b8;
--color-text-disabled: #475569;
--color-accent: #818cf8;
--color-accent-hover: #a5b4fc;
--color-accent-light: rgba(129,140,248,0.15);
--color-border: #334155;
--color-border-light: #1e293b;
--color-border-hover: #475569;
--color-sidebar-bg: #1e293b;
--color-sidebar-hover: #334155;
--color-sidebar-active: #475569;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.2);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.4);
--shadow-xl: 0 20px 48px rgba(0,0,0,0.5);
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* 全局基础 */
/* ═══════════════════════════════════════════════════════════════════════════ */
*, *::before, *::after {
box-sizing: border-box;
border-color: var(--border);
}
html {
font-size: var(--text-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--foreground);
background-color: var(--background);
line-height: 1.6;
overflow: hidden;
overflow-x: hidden;
height: 100vh;
}
#root {
height: 100vh;
overflow: hidden;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-foreground);
text-decoration: underline;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font-family: inherit;
font-size: inherit;
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* 滚动条 */
/* ═══════════════════════════════════════════════════════════════════════════ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.2);
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.25);
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* 通用工具类 (旧页面兼容) */
/* ═══════════════════════════════════════════════════════════════════════════ */
.page-container {
padding: var(--space-2xl);
height: 100%;
overflow-y: auto;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.4;
}
.badge-success {
background: var(--color-success-light);
color: var(--color-success);
}
.badge-warning {
background: var(--color-warning-light);
color: var(--color-warning);
}
.badge-error {
background: var(--color-error-light);
color: var(--color-error);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.btn-primary:hover {
opacity: 0.9;
box-shadow: var(--shadow-sm);
}
.btn-outline {
background: transparent;
color: var(--foreground);
border-color: var(--border);
}
.btn-outline:hover {
background: var(--muted);
}
.input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--background);
color: var(--foreground);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(91,95,237,0.1);
}
/* ═══════════════════════════════════════════════════════════════════════════ */
/* 动画 */
/* ═══════════════════════════════════════════════════════════════════════════ */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.2s ease;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />