#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