feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包
This commit is contained in:
1 parent
1ba81ba0d1
commit
8f30a588fb
6 files changed
+731
-43
No files matched your search
@@ -31,6 +31,12 @@ async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return env.data;
|
return env.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
|
||||||
|
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const env = await request<{ success: boolean; data: T }>(path, options);
|
||||||
|
return env.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@@ -56,13 +62,19 @@ export const authApi = {
|
|||||||
|
|
||||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
|
||||||
|
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
|
||||||
|
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
model: string;
|
model_id: string;
|
||||||
|
status?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
source?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
message_count: number;
|
message_count?: number; // 后端未返回,前端可选兼容
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -70,24 +82,25 @@ export interface ChatMessage {
|
|||||||
conversation_id: string;
|
conversation_id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
model: string;
|
model_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
token_used?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatApi = {
|
export const chatApi = {
|
||||||
listConversations: () => request<Conversation[]>('/chat/conversations'),
|
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||||
|
|
||||||
createConversation: (data: { title?: string; model?: string }) =>
|
createConversation: (payload: { title?: string; model?: string }) =>
|
||||||
request<Conversation>('/chat/conversations', {
|
data<Conversation>('/chat/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteConversation: (id: string) =>
|
deleteConversation: (id: string) =>
|
||||||
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
getMessages: (convId: string) =>
|
getMessages: (convId: string) =>
|
||||||
request<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||||
|
|
||||||
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||||
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||||
@@ -130,23 +143,41 @@ export const chatApi = {
|
|||||||
|
|
||||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (knowledge.py):
|
||||||
|
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
|
||||||
|
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
|
||||||
export interface KnowledgeBase {
|
export interface KnowledgeBase {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
document_count: number;
|
document_count: number;
|
||||||
status: 'active' | 'building' | 'error';
|
status: 'active' | 'building' | 'error' | string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const knowledgeApi = {
|
export interface KnowledgeBaseList {
|
||||||
listBases: () => request<KnowledgeBase[]>('/knowledge/bases'),
|
bases: KnowledgeBase[];
|
||||||
|
storage_used: number;
|
||||||
|
storage_limit: number;
|
||||||
|
membership_level: number;
|
||||||
|
}
|
||||||
|
|
||||||
createBase: (data: { name: string; description?: string }) =>
|
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||||
request<KnowledgeBase>('/knowledge/bases', {
|
export interface KnowledgeBaseCreated {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeApi = {
|
||||||
|
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
|
||||||
|
|
||||||
|
createBase: (payload: { name: string; description?: string }) =>
|
||||||
|
data<KnowledgeBaseCreated>('/knowledge/bases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteBase: (id: string) =>
|
deleteBase: (id: string) =>
|
||||||
@@ -240,6 +271,18 @@ export interface MarketAppList {
|
|||||||
|
|
||||||
export type MarketSort = 'latest' | 'popular' | 'rating';
|
export type MarketSort = 'latest' | 'popular' | 'rating';
|
||||||
|
|
||||||
|
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
|
||||||
|
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
|
||||||
|
export interface MyApp {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
type: MarketAppType;
|
||||||
|
price: number;
|
||||||
|
purchased_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const marketApi = {
|
export const marketApi = {
|
||||||
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||||
listApps: (params: {
|
listApps: (params: {
|
||||||
@@ -267,7 +310,7 @@ export const marketApi = {
|
|||||||
data<MarketAppDetail>(`/apps/${appId}`),
|
data<MarketAppDetail>(`/apps/${appId}`),
|
||||||
|
|
||||||
getMyApps: () =>
|
getMyApps: () =>
|
||||||
request<MarketApp[]>('/my-apps'),
|
data<MyApp[]>('/my-apps'),
|
||||||
|
|
||||||
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -299,16 +342,22 @@ export const marketApi = {
|
|||||||
|
|
||||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
|
||||||
|
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
|
||||||
export interface ImBotStatus {
|
export interface ImBotStatus {
|
||||||
status: 'connected' | 'disconnected' | 'expired';
|
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||||
ilink_bot_id: string;
|
ilink_bot_id: string | null;
|
||||||
connected_at: string;
|
connected_at: string | null;
|
||||||
last_active: string;
|
last_active?: string | null;
|
||||||
message_count: number;
|
message_count?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
baseurl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imBotApi = {
|
export const imBotApi = {
|
||||||
getStatus: () => request<ImBotStatus>('/im-bot/status'),
|
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
|
||||||
|
|
||||||
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
profileApi,
|
profileApi,
|
||||||
tokenApi,
|
tokenApi,
|
||||||
type TokenOverview,
|
type TokenOverview,
|
||||||
type MarketApp,
|
type MyApp,
|
||||||
type MarketAd,
|
type MarketAd,
|
||||||
type MarketSort,
|
type MarketSort,
|
||||||
type MarketAppType,
|
type MarketAppType,
|
||||||
@@ -91,6 +91,16 @@ export function useKnowledgeBases() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['knowledge', 'bases'],
|
queryKey: ['knowledge', 'bases'],
|
||||||
queryFn: knowledgeApi.listBases,
|
queryFn: knowledgeApi.listBases,
|
||||||
|
select: (d) => {
|
||||||
|
const bases = Array.isArray(d?.bases) ? d.bases : [];
|
||||||
|
// 后端 create 返回缺 document_count/status/updated_at,这里补默认值避免渲染崩溃
|
||||||
|
return bases.map((kb) => ({
|
||||||
|
...kb,
|
||||||
|
document_count: kb.document_count ?? 0,
|
||||||
|
status: kb.status ?? 'active',
|
||||||
|
updated_at: kb.updated_at ?? kb.created_at ?? '',
|
||||||
|
}));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +169,7 @@ export function useMyApps() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['market', 'my-apps'],
|
queryKey: ['market', 'my-apps'],
|
||||||
queryFn: marketApi.getMyApps,
|
queryFn: marketApi.getMyApps,
|
||||||
select: (d: unknown) => asArray<MarketApp>(d),
|
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,22 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Settings,
|
Settings,
|
||||||
Play,
|
|
||||||
Globe,
|
Globe,
|
||||||
Monitor,
|
Monitor,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMyApps } from '@/hooks/useApi';
|
import { useMyApps } from '@/hooks/useApi';
|
||||||
import type { MarketApp } from '@/api/client';
|
import type { MyApp } from '@/api/client';
|
||||||
|
|
||||||
export function AppServices() {
|
export function AppServices() {
|
||||||
const { data: rawApps, isLoading } = useMyApps();
|
const { data: apps = [], isLoading } = useMyApps();
|
||||||
const apps: MarketApp[] = Array.isArray(rawApps) ? rawApps : (((rawApps as any)?.data ?? (rawApps as any)?.items) ?? []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">管理第三方 AI 应用</p>
|
<p className="text-sm text-muted-foreground mt-1">管理已安装的 AI 应用</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="gap-2">
|
<Button className="gap-2">
|
||||||
<Plus size={16} /> 创建应用
|
<Plus size={16} /> 创建应用
|
||||||
@@ -48,29 +46,30 @@ export function AppServices() {
|
|||||||
<p className="text-sm">去应用市场浏览并安装应用</p>
|
<p className="text-sm">去应用市场浏览并安装应用</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
apps.map((app: MarketApp) => (
|
apps.map((app: MyApp) => (
|
||||||
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
{app.type === 'standalone' ? (
|
{app.icon ? (
|
||||||
|
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
|
||||||
|
) : app.type === 'standalone' ? (
|
||||||
<Globe size={20} className="text-primary" />
|
<Globe size={20} className="text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<Monitor size={20} className="text-primary" />
|
<Monitor size={20} className="text-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={app.installed ? 'success' : 'secondary'}>
|
<Badge variant="success">已安装</Badge>
|
||||||
{app.installed ? '已安装' : '未安装'}
|
<Badge variant="outline">{app.type}</Badge>
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{app.status === 'published' ? '已发布' : app.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
<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>
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'}
|
||||||
|
{app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
<Separator className="mb-4" />
|
<Separator className="mb-4" />
|
||||||
|
|
||||||
@@ -81,13 +80,9 @@ export function AppServices() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
variant={app.installed ? 'default' : 'outline'}
|
variant="default"
|
||||||
>
|
>
|
||||||
{app.installed ? (
|
<ExternalLink size={14} /> 打开
|
||||||
<><ExternalLink size={14} /> 打开</>
|
|
||||||
) : (
|
|
||||||
<><Play size={14} /> 安装</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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_id || '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>
|
||||||
|
连接于 {wechatStatus.connected_at ? 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-primary/10 flex items-center justify-center">
|
||||||
|
<Database size={20} className="text-primary" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user