feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包

This commit is contained in:
QihuAI Design committed 2026-08-23 15:54:43 +08:00
1 parent 1ba81ba0d1
commit 8f30a588fb
6 files changed
+731 -43

No files matched your search

+70 -21
View File
@@ -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'),
+12 -2
View File
@@ -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>
);
}