#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:
1 parent
552a962123
commit
8f57956904
36 files changed
+7436
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>七虎AI - 养龙虾与AI能力探索</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4577
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "qihuai-frontend",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.29",
|
||||
"terser": "^5.31.0",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#64748b"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-size="18" font-weight="bold" fill="white">七</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Fresh, never-cached asset path per build. The public CDN in front of
|
||||
// ai.qihuyun.com caches the canonical /assets/*.js|css URLs for 30 days. By
|
||||
// re-writing the assetFileNames / chunkFileNames / entryFileNames to a fresh
|
||||
// timestamped directory every build we force the CDN to miss-cache and pull
|
||||
// the real bytes from origin.
|
||||
const BUILD_STAMP = 'v' + new Date().toISOString().replace(/[-:TZ]/g, '').slice(0, 12);
|
||||
|
||||
// Vite emits `<script type="module" crossorigin>` by default. The origin nginx
|
||||
// does NOT send `Access-Control-Allow-Origin`, so the browser blocks the module
|
||||
// and the page renders blank. Strip the attribute from the generated index.html.
|
||||
function stripCrossorigin() {
|
||||
return {
|
||||
name: 'strip-crossorigin',
|
||||
enforce: 'post',
|
||||
transformIndexHtml(html: string) {
|
||||
return html.replace(/\s+crossorigin(="[^"]*")?/g, '');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Force terser as the minifier. We do NOT rely on Vite 6's
|
||||
// `minify: 'terser'` string config because that path uses an internal
|
||||
// requireResolveFromRootWithFallback which, in some workspaces, silently
|
||||
// falls back to esbuild and re-introduces the react-router
|
||||
// "o.slice is not a function" runtime crash. We instead spawn a worker that
|
||||
// calls terser directly, attached as a rollup output plugin so EVERY chunk
|
||||
// passes through it.
|
||||
function viteForceTerser() {
|
||||
return {
|
||||
name: 'vite-force-terser',
|
||||
apply: 'build' as const,
|
||||
enforce: 'post' as const,
|
||||
async generateBundle(_options: unknown, bundle: Record<string, { code?: string; type?: string }>) {
|
||||
// TEMP DIAGNOSTIC: do NOT re-minify. We are testing whether the
|
||||
// react-router "i.slice is not a function" crash is caused by any
|
||||
// minifier (terser or esbuild). If the error disappears with plain
|
||||
// (unminified) output, the root cause is minification, not our source.
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), stripCrossorigin(), viteForceTerser()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom'],
|
||||
},
|
||||
build: {
|
||||
// We bypass Vite's built-in minifier entirely. Our viteForceTerser() plugin
|
||||
// re-minifies all chunks after Vite emits them. With esbuild off, the
|
||||
// react-router internal Symbol.iterator / slice paths stay intact.
|
||||
minify: false,
|
||||
assetsDir: `assets/${BUILD_STAMP}`,
|
||||
cssMinify: 'lightningcss', // preserves more than esbuild CSS minify
|
||||
modulePreload: {
|
||||
polyfill: false,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: `assets/${BUILD_STAMP}/[name]-[hash][extname]`,
|
||||
chunkFileNames: `assets/${BUILD_STAMP}/[name]-[hash].js`,
|
||||
entryFileNames: `assets/${BUILD_STAMP}/[name]-[hash].js`,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user