Compare commits
12
Commits
552a962123
...
25f3edac0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f3edac0a | ||
|
|
7b8c3ba2dd | ||
|
|
8f30a588fb | ||
|
|
1ba81ba0d1 | ||
|
|
66f696c1e6 | ||
|
|
09440c2577 | ||
|
|
8a9c2e4868 | ||
|
|
39fcdbf6da | ||
|
|
910394423d | ||
|
|
4a2920e637 | ||
|
|
6f192a89cf | ||
|
|
8f57956904 |
No files matched your search
@@ -0,0 +1,126 @@
|
|||||||
|
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';
|
||||||
|
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||||
|
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||||
|
import { MarketPage } from '@/pages/front/MarketPage';
|
||||||
|
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||||
|
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||||
|
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Puzzle,
|
||||||
|
Workflow,
|
||||||
|
Shield,
|
||||||
|
Radio,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
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 />} />
|
||||||
|
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
|
||||||
|
<Route path="market" element={<MarketPage />} />
|
||||||
|
<Route
|
||||||
|
path="agents"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="Agent 智能体"
|
||||||
|
icon={Bot}
|
||||||
|
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
|
||||||
|
features={[
|
||||||
|
'拖拽式 Agent 编排画布',
|
||||||
|
'多 Agent 协作与对话路由',
|
||||||
|
'绑定知识库与 MCP 工具',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="mcp"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="MCP 工具"
|
||||||
|
icon={Puzzle}
|
||||||
|
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
|
||||||
|
features={[
|
||||||
|
'粘贴 MCP 配置一键连接',
|
||||||
|
'工具列表与连通性测试',
|
||||||
|
'官方与社区 MCP 应用市场',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="workflows"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="工作流"
|
||||||
|
icon={Workflow}
|
||||||
|
description="用可视化 DAG 编排多步骤自动化流程。"
|
||||||
|
features={[
|
||||||
|
'节点拖拽编排',
|
||||||
|
'条件分支与并行执行',
|
||||||
|
'可导出可移植的 YAML 规范',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="rules"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="规则库"
|
||||||
|
icon={Shield}
|
||||||
|
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
|
||||||
|
features={[
|
||||||
|
'工具调用四级风险审批',
|
||||||
|
'Loop 原语与验证器',
|
||||||
|
'企业级合规策略模板',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="channels"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="渠道"
|
||||||
|
icon={Radio}
|
||||||
|
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
|
||||||
|
features={[
|
||||||
|
'微信公众号 / 个人微信接入',
|
||||||
|
'IM Bot 多端统一适配',
|
||||||
|
'渠道级权限与配额',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="evolution" element={<EvolutionPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend responses use a unified envelope `{code, data, message}`.
|
||||||
|
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
|
||||||
|
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||||
|
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 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (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 {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model_id: string;
|
||||||
|
status?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
source?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
message_count?: number; // 后端未返回,前端可选兼容
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model_id: string;
|
||||||
|
created_at: string;
|
||||||
|
token_used?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatApi = {
|
||||||
|
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||||
|
|
||||||
|
createConversation: (payload: { title?: string; model?: string }) =>
|
||||||
|
data<Conversation>('/chat/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteConversation: (id: string) =>
|
||||||
|
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getMessages: (convId: string) =>
|
||||||
|
data<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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
document_count: number;
|
||||||
|
status: 'active' | 'building' | 'error' | string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseList {
|
||||||
|
bases: KnowledgeBase[];
|
||||||
|
storage_used: number;
|
||||||
|
storage_limit: number;
|
||||||
|
membership_level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||||
|
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',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
||||||
|
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
|
||||||
|
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
|
||||||
|
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
|
||||||
|
|
||||||
|
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
|
||||||
|
export type MarketAppSource = 'official' | 'community' | 'third_party';
|
||||||
|
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
|
||||||
|
|
||||||
|
export interface MarketApp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string | null;
|
||||||
|
type: MarketAppType;
|
||||||
|
source: MarketAppSource;
|
||||||
|
publisher_id: string;
|
||||||
|
publisher_name: string;
|
||||||
|
is_member_free: boolean;
|
||||||
|
join_promotion: boolean;
|
||||||
|
status: MarketAppStatus;
|
||||||
|
price: number;
|
||||||
|
install_count: number;
|
||||||
|
rating_avg: number;
|
||||||
|
review_count: number;
|
||||||
|
created_at: string;
|
||||||
|
capability: string | null;
|
||||||
|
category: string | null;
|
||||||
|
tags: string[];
|
||||||
|
installed: boolean;
|
||||||
|
version: string | null;
|
||||||
|
security_scan: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
dependencies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
|
||||||
|
export interface MarketAppDetail extends MarketApp {
|
||||||
|
long_description?: string;
|
||||||
|
screenshots?: string[];
|
||||||
|
homepage_url?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReview {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReviewList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: MarketReview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketAd {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image_url: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketAppList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: MarketApp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||||
|
listApps: (params: {
|
||||||
|
type?: MarketAppType;
|
||||||
|
source?: MarketAppSource;
|
||||||
|
capability?: string;
|
||||||
|
search?: string;
|
||||||
|
sort?: MarketSort;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.type) qs.set('type', params.type);
|
||||||
|
if (params.source) qs.set('source', params.source);
|
||||||
|
if (params.capability) qs.set('capability', params.capability);
|
||||||
|
if (params.search) qs.set('search', params.search);
|
||||||
|
if (params.sort) qs.set('sort', params.sort);
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getApp: (appId: string) =>
|
||||||
|
data<MarketAppDetail>(`/apps/${appId}`),
|
||||||
|
|
||||||
|
getMyApps: () =>
|
||||||
|
data<MyApp[]>('/my-apps'),
|
||||||
|
|
||||||
|
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAds: () =>
|
||||||
|
data<MarketAd[]>('/ads'),
|
||||||
|
|
||||||
|
// ─── 写操作(后续迭代启用)───
|
||||||
|
installApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/install`, { method: 'POST' }),
|
||||||
|
|
||||||
|
uninstallApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
purchaseApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/purchase`, { method: 'POST' }),
|
||||||
|
|
||||||
|
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
|
||||||
|
request(`/apps/${appId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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 {
|
||||||
|
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||||
|
ilink_bot_id: string | null;
|
||||||
|
connected_at: string | null;
|
||||||
|
last_active?: string | null;
|
||||||
|
message_count?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
baseurl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imBotApi = {
|
||||||
|
getStatus: () => successData<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 MembershipInfo {
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
status: string;
|
||||||
|
start_at: string;
|
||||||
|
expire_at: string;
|
||||||
|
is_expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
avatar: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
membership: MembershipInfo | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogItem {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
user_agent: string;
|
||||||
|
login_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: LoginLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_preview: string;
|
||||||
|
status: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
expire_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
|
||||||
|
export interface ApiKeyCreated {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api_key: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
|
||||||
|
|
||||||
|
export interface PurchasedAppItem {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_icon: string;
|
||||||
|
app_type: string;
|
||||||
|
price: number;
|
||||||
|
purchased_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileApi = {
|
||||||
|
get: () => data<UserProfile>('/profile/'),
|
||||||
|
|
||||||
|
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
|
||||||
|
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
|
getBalance: () => data<number>('/profile/balance'),
|
||||||
|
|
||||||
|
getMembership: () => data<{
|
||||||
|
has_membership: boolean;
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
status: string;
|
||||||
|
start_at: string;
|
||||||
|
expire_at: string;
|
||||||
|
is_expired: boolean;
|
||||||
|
}>('/profile/membership'),
|
||||||
|
|
||||||
|
getLoginLogs: (page = 1, page_size = 10) =>
|
||||||
|
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
|
||||||
|
|
||||||
|
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||||
|
|
||||||
|
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
|
||||||
|
|
||||||
|
// ─── 写操作 ───
|
||||||
|
|
||||||
|
updatePassword: (payload: { old_password: string; new_password: string }) =>
|
||||||
|
request<{ code: number; message: string }>('/profile/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
createApiKey: (name: string) =>
|
||||||
|
data<ApiKeyCreated>('/profile/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
|
||||||
|
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TokenOverview {
|
||||||
|
total_used: number;
|
||||||
|
balance: number;
|
||||||
|
today_apps: number;
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRanking {
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
usage_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopApp {
|
||||||
|
conversation_id: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenRankings {
|
||||||
|
model_rankings: ModelRanking[];
|
||||||
|
top_apps: TopApp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenLogItem {
|
||||||
|
id: string;
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
conversation_id: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenLogPage {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: TokenLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBalance {
|
||||||
|
balance: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokenApi = {
|
||||||
|
getOverview: () => data<TokenOverview>('/token/overview'),
|
||||||
|
|
||||||
|
getRankings: () => data<TokenRankings>('/token/rankings'),
|
||||||
|
|
||||||
|
getLogs: (params: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
model_id?: string;
|
||||||
|
app_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.start_date) qs.set('start_date', params.start_date);
|
||||||
|
if (params.end_date) qs.set('end_date', params.end_date);
|
||||||
|
if (params.model_id) qs.set('model_id', params.model_id);
|
||||||
|
if (params.app_type) qs.set('app_type', params.app_type);
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||||
|
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||||
|
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||||
|
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||||
|
// GET /unread-count → {code,data:{count},message}
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
type: string | null;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
is_read: boolean;
|
||||||
|
related_id: string | null;
|
||||||
|
related_type: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: NotificationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.type) qs.set('type', params.type);
|
||||||
|
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead: (id: string) =>
|
||||||
|
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||||
|
|
||||||
|
markAllRead: (type?: string) =>
|
||||||
|
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||||
|
|
||||||
|
unreadCount: () =>
|
||||||
|
data<{ count: number }>('/notifications/unread-count'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||||
|
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||||
|
// 技能状态 state: main | staging | baby | frozen
|
||||||
|
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||||
|
|
||||||
|
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||||
|
|
||||||
|
export interface EvolutionSkill {
|
||||||
|
id: string;
|
||||||
|
skill_name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
state: EvolutionSkillState;
|
||||||
|
use_count: number;
|
||||||
|
version: number;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
skill_md_content?: string | null; // 仅 detail 返回
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionEvent {
|
||||||
|
id: string;
|
||||||
|
skill_name: string;
|
||||||
|
event_type: string;
|
||||||
|
description: string;
|
||||||
|
version_before: number | null;
|
||||||
|
version_after: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionTrajectory {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
file_path: string | null;
|
||||||
|
status: string;
|
||||||
|
atom_count: number | null;
|
||||||
|
token_used: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
processed_at: string | null;
|
||||||
|
weightscore?: number;
|
||||||
|
atom_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionSkillTree {
|
||||||
|
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||||
|
total_count: number;
|
||||||
|
main_count: number;
|
||||||
|
baby_count: number;
|
||||||
|
staging_count: number;
|
||||||
|
frozen_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionStatistics {
|
||||||
|
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||||
|
total_use_count: number;
|
||||||
|
category_distribution: { category: string; count: number }[];
|
||||||
|
daily_trend: { date: string; count: number }[];
|
||||||
|
low_use_skills: EvolutionSkill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionDimension {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
maxValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionProfile {
|
||||||
|
level: number;
|
||||||
|
level_title: string;
|
||||||
|
total_skills: number;
|
||||||
|
active_skills: number;
|
||||||
|
staging_skills: number;
|
||||||
|
baby_skills: number;
|
||||||
|
total_use_count: number;
|
||||||
|
dimensions: EvolutionDimension[];
|
||||||
|
tags: string[];
|
||||||
|
growth_suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionPipelineStatus {
|
||||||
|
scheduler_running: boolean;
|
||||||
|
watcher_status: string;
|
||||||
|
pipeline_status: string;
|
||||||
|
discovered_count: number;
|
||||||
|
done_count: number;
|
||||||
|
total_skills: number;
|
||||||
|
total_trajectories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionWatcherStatus {
|
||||||
|
user_id: string;
|
||||||
|
scheduler_running: boolean;
|
||||||
|
watcher_status: string;
|
||||||
|
pipeline_status: string;
|
||||||
|
pipeline_processed: number;
|
||||||
|
watcher_error: string | null;
|
||||||
|
skill_count: number;
|
||||||
|
trajectory_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionReport {
|
||||||
|
id: string;
|
||||||
|
report_type: string;
|
||||||
|
period_start: string | null;
|
||||||
|
period_end: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionReportDetail extends EvolutionReport {
|
||||||
|
user_id: string;
|
||||||
|
report_data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evolutionApi = {
|
||||||
|
// 技能管理
|
||||||
|
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.state) qs.set('state', params.state);
|
||||||
|
if (params.keyword) qs.set('keyword', params.keyword);
|
||||||
|
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||||
|
if (params.order) qs.set('order', params.order);
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSkillDetail: (skillName: string) =>
|
||||||
|
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||||
|
|
||||||
|
// 进化事件
|
||||||
|
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.event_type) qs.set('event_type', params.event_type);
|
||||||
|
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 驾驶舱
|
||||||
|
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||||
|
getTimeline: (event_type?: string) =>
|
||||||
|
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||||
|
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||||
|
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||||
|
|
||||||
|
// 流水线 / Watcher
|
||||||
|
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||||
|
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||||
|
|
||||||
|
// 报告
|
||||||
|
listReports: (report_type?: string) =>
|
||||||
|
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||||
|
getReportDetail: (reportId: string) =>
|
||||||
|
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
authApi,
|
||||||
|
chatApi,
|
||||||
|
knowledgeApi,
|
||||||
|
marketApi,
|
||||||
|
imBotApi,
|
||||||
|
profileApi,
|
||||||
|
tokenApi,
|
||||||
|
notificationsApi,
|
||||||
|
evolutionApi,
|
||||||
|
type TokenOverview,
|
||||||
|
type MyApp,
|
||||||
|
type MarketAd,
|
||||||
|
type MarketSort,
|
||||||
|
type MarketAppType,
|
||||||
|
type MarketAppSource,
|
||||||
|
type EvolutionSkill,
|
||||||
|
type EvolutionEvent,
|
||||||
|
type EvolutionReport,
|
||||||
|
} 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,
|
||||||
|
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 ?? '',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 interface MarketAppsParams {
|
||||||
|
type?: MarketAppType;
|
||||||
|
source?: MarketAppSource;
|
||||||
|
capability?: string;
|
||||||
|
search?: string;
|
||||||
|
sort?: MarketSort;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketApps(params: MarketAppsParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'apps', params],
|
||||||
|
queryFn: () => marketApi.listApps(params),
|
||||||
|
placeholderData: (prev) => prev, // 翻页/筛选时保留旧数据,避免闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketApp(appId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'app', appId],
|
||||||
|
queryFn: () => marketApi.getApp(appId!),
|
||||||
|
enabled: !!appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMyApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'my-apps'],
|
||||||
|
queryFn: marketApi.getMyApps,
|
||||||
|
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketReviews(appId: string | null, params: { page?: number; page_size?: number } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'reviews', appId, params],
|
||||||
|
queryFn: () => marketApi.getReviews(appId!, params),
|
||||||
|
enabled: !!appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketAds() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'ads'],
|
||||||
|
queryFn: marketApi.getAds,
|
||||||
|
select: (d: unknown) => asArray<MarketAd>(d),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstallApp() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (appId: string) => marketApi.installApp(appId),
|
||||||
|
onSuccess: (_res, appId) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUninstallApp() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (appId: string) => marketApi.uninstallApp(appId),
|
||||||
|
onSuccess: (_res, appId) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileBalance() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'balance'],
|
||||||
|
queryFn: profileApi.getBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileMembership() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'membership'],
|
||||||
|
queryFn: profileApi.getMembership,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileLoginLogs(page = 1, page_size = 10) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'login-logs', page, page_size],
|
||||||
|
queryFn: () => profileApi.getLoginLogs(page, page_size),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileApiKeys() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'api-keys'],
|
||||||
|
queryFn: profileApi.getApiKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfilePurchasedApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'purchased-apps'],
|
||||||
|
queryFn: profileApi.getPurchasedApps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateProfilePassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { old_password: string; new_password: string }) =>
|
||||||
|
profileApi.updatePassword(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApiKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => profileApi.createApiKey(name),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateApiKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
|
||||||
|
profileApi.updateApiKey(keyId, status),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||||
|
// so the homepage never ships fake/mock values.
|
||||||
|
export function useDashboardStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'overview'],
|
||||||
|
queryFn: tokenApi.getOverview,
|
||||||
|
select: (d: TokenOverview) => ({
|
||||||
|
active_users: d.today_apps,
|
||||||
|
today_conversations: d.today_apps,
|
||||||
|
skill_count: d.total_used,
|
||||||
|
revenue: d.balance,
|
||||||
|
user_change: '',
|
||||||
|
conv_change: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenOverview() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'overview'],
|
||||||
|
queryFn: tokenApi.getOverview,
|
||||||
|
refetchInterval: 60000, // 每分钟刷新一次概览
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenRankings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'rankings'],
|
||||||
|
queryFn: tokenApi.getRankings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenBalance() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'balance'],
|
||||||
|
queryFn: tokenApi.getBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenLogs(params: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
model_id?: string;
|
||||||
|
app_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'logs', params],
|
||||||
|
queryFn: () => tokenApi.getLogs(params),
|
||||||
|
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notifications 消息通知 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useNotifications(params: {
|
||||||
|
type?: string;
|
||||||
|
is_read?: boolean;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
} = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['notifications', 'list', params],
|
||||||
|
queryFn: () => notificationsApi.list(params),
|
||||||
|
placeholderData: (prev) => prev, // 翻页/筛选保留旧数据,避免闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadCount() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['notifications', 'unread-count'],
|
||||||
|
queryFn: notificationsApi.unreadCount,
|
||||||
|
refetchInterval: 60000, // 每分钟刷新未读计数
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkNotificationRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAllNotificationsRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (type?: string) => notificationsApi.markAllRead(type),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useEvolutionSkills(params: {
|
||||||
|
state?: string;
|
||||||
|
keyword?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
order?: string;
|
||||||
|
} = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'skills', params],
|
||||||
|
queryFn: () => evolutionApi.listSkills(params),
|
||||||
|
select: (d: EvolutionSkill[]) => (Array.isArray(d) ? d : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionEvents(params: {
|
||||||
|
event_type?: string;
|
||||||
|
skill_name?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'events', params],
|
||||||
|
queryFn: () => evolutionApi.listEvents(params),
|
||||||
|
select: (d: EvolutionEvent[]) => (Array.isArray(d) ? d : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionSkillTree() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'skill-tree'],
|
||||||
|
queryFn: evolutionApi.getSkillTree,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionStatistics() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'statistics'],
|
||||||
|
queryFn: evolutionApi.getStatistics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionProfile() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'profile'],
|
||||||
|
queryFn: evolutionApi.getProfile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionPipelineStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'pipeline-status'],
|
||||||
|
queryFn: evolutionApi.getPipelineStatus,
|
||||||
|
refetchInterval: 30000, // 流水线状态每 30s 轮询
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionWatcherStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'watcher-status'],
|
||||||
|
queryFn: evolutionApi.getWatcherStatus,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionReports(report_type?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolution', 'reports', report_type],
|
||||||
|
queryFn: () => evolutionApi.listReports(report_type),
|
||||||
|
select: (d: EvolutionReport[]) => (Array.isArray(d) ? d : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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,
|
||||||
|
Globe,
|
||||||
|
Monitor,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useMyApps } from '@/hooks/useApi';
|
||||||
|
import type { MyApp } from '@/api/client';
|
||||||
|
|
||||||
|
export function AppServices() {
|
||||||
|
const { data: apps = [], isLoading } = useMyApps();
|
||||||
|
|
||||||
|
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: MyApp) => (
|
||||||
|
<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-primary/10 flex items-center justify-center">
|
||||||
|
{app.icon ? (
|
||||||
|
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
|
||||||
|
) : app.type === 'standalone' ? (
|
||||||
|
<Globe size={20} className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<Monitor size={20} className="text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="success">已安装</Badge>
|
||||||
|
<Badge variant="outline">{app.type}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<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="default"
|
||||||
|
>
|
||||||
|
<ExternalLink 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_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,451 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
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 {
|
||||||
|
Sparkles,
|
||||||
|
Brain,
|
||||||
|
Network,
|
||||||
|
BarChart3,
|
||||||
|
Activity,
|
||||||
|
GitBranch,
|
||||||
|
RefreshCw,
|
||||||
|
Cpu,
|
||||||
|
ArrowUpRight,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
Layers,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useEvolutionProfile,
|
||||||
|
useEvolutionSkills,
|
||||||
|
useEvolutionStatistics,
|
||||||
|
useEvolutionPipelineStatus,
|
||||||
|
useEvolutionEvents,
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import type {
|
||||||
|
EvolutionSkill,
|
||||||
|
EvolutionSkillState,
|
||||||
|
EvolutionStatistics,
|
||||||
|
EvolutionProfile,
|
||||||
|
EvolutionPipelineStatus,
|
||||||
|
EvolutionEvent,
|
||||||
|
} from '@/api/client';
|
||||||
|
|
||||||
|
// 技能状态 → 中文标签 + 颜色
|
||||||
|
const STATE_LABELS: Record<EvolutionSkillState, string> = {
|
||||||
|
main: '成熟',
|
||||||
|
staging: '灰度',
|
||||||
|
baby: '萌芽',
|
||||||
|
frozen: '冻结',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_BADGE: Record<EvolutionSkillState, 'default' | 'secondary' | 'outline' | 'success'> = {
|
||||||
|
main: 'success',
|
||||||
|
staging: 'default',
|
||||||
|
baby: 'secondary',
|
||||||
|
frozen: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 雷达/维度条
|
||||||
|
function DimensionBars({ dimensions }: { dimensions: EvolutionProfile['dimensions'] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dimensions.map((dim) => {
|
||||||
|
const pct = Math.round((dim.value / dim.maxValue) * 100);
|
||||||
|
return (
|
||||||
|
<div key={dim.label}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-muted-foreground">{dim.label}</span>
|
||||||
|
<span className="font-medium text-foreground">{dim.value.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 能力画像卡
|
||||||
|
function ProfileCard({ profile }: { profile: EvolutionProfile }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-amber-50/40 dark:to-amber-950/20">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<Sparkles size={30} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-xl font-bold text-foreground">
|
||||||
|
Lv.{profile.level} {profile.level_title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
共 {profile.total_skills} 个技能 · 累计调用 {profile.total_use_count} 次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-5" />
|
||||||
|
|
||||||
|
<DimensionBars dimensions={profile.dimensions} />
|
||||||
|
|
||||||
|
{profile.growth_suggestions.length > 0 && (
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
|
<TrendingUp size={15} className="text-primary" />
|
||||||
|
成长建议
|
||||||
|
</p>
|
||||||
|
{profile.growth_suggestions.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
|
<ArrowUpRight size={14} className="text-primary mt-0.5 shrink-0" />
|
||||||
|
<span>{s}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-4">
|
||||||
|
{profile.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-xs">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技能网格
|
||||||
|
function SkillCard({ skill }: { skill: EvolutionSkill }) {
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<Cpu size={17} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground truncate">{skill.skill_name}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATE_BADGE[skill.state] ?? 'outline'} className="shrink-0">
|
||||||
|
{STATE_LABELS[skill.state] ?? skill.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2rem]">
|
||||||
|
{skill.description || '暂无描述'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>调用 {skill.use_count}</span>
|
||||||
|
<span>v{skill.version}</span>
|
||||||
|
</div>
|
||||||
|
{skill.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{skill.tags.slice(0, 3).map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-[10px] py-0">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流水线状态条
|
||||||
|
function PipelineStatusCard({ status }: { status: EvolutionPipelineStatus }) {
|
||||||
|
const running = status.scheduler_running;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Activity size={18} className="text-primary" />
|
||||||
|
<h3 className="font-semibold text-foreground">进化流水线</h3>
|
||||||
|
<Badge
|
||||||
|
variant={running ? 'success' : 'outline'}
|
||||||
|
className="ml-auto gap-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'w-1.5 h-1.5 rounded-full ' +
|
||||||
|
(running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{running ? '运行中' : '未运行'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||||
|
<Stat label="待处理轨迹" value={status.discovered_count} />
|
||||||
|
<Stat label="已完成轨迹" value={status.done_count} />
|
||||||
|
<Stat label="技能总数" value={status.total_skills} />
|
||||||
|
<Stat label="轨迹总数" value={status.total_trajectories} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
流水线状态:{status.pipeline_status} · 引擎:{status.watcher_status}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-muted/50 py-3">
|
||||||
|
<p className="text-xl font-bold text-foreground">{value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计卡(Top 技能 + 类别分布)
|
||||||
|
function StatisticsCard({ stats }: { stats: EvolutionStatistics }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BarChart3 size={18} className="text-primary" />
|
||||||
|
<h3 className="font-semibold text-foreground">使用统计</h3>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
总调用 {stats.total_use_count}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 技能 */}
|
||||||
|
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<TrendingUp size={14} className="text-primary" />
|
||||||
|
热门技能 Top {stats.ranking.length}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.ranking.slice(0, 5).map((r, i) => {
|
||||||
|
const max = stats.ranking[0]?.use_count || 1;
|
||||||
|
const pct = Math.round((r.use_count / max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={r.skill_name}>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-0.5">
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{i + 1}. {r.skill_name}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground shrink-0 ml-2">
|
||||||
|
{r.use_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{stats.ranking.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">暂无使用数据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{/* 类别分布 */}
|
||||||
|
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<Layers size={14} className="text-primary" />
|
||||||
|
类别分布
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats.category_distribution.map((c) => (
|
||||||
|
<Badge key={c.category} variant="secondary" className="gap-1">
|
||||||
|
{c.category}
|
||||||
|
<span className="text-primary font-medium">{c.count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{stats.category_distribution.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">暂无分布数据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件流
|
||||||
|
function EventFeed({ events }: { events: EvolutionEvent[] }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<GitBranch size={18} className="text-primary" />
|
||||||
|
<h3 className="font-semibold text-foreground">进化事件流</h3>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无进化事件</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||||
|
{events.map((e) => (
|
||||||
|
<div key={e.id} className="flex items-start gap-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary mt-2 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-foreground">{e.description}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{e.skill_name}
|
||||||
|
{e.version_after != null && ` · v${e.version_after}`} ·{' '}
|
||||||
|
{formatTime(e.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||||
|
{e.event_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvolutionPage() {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const { data: profile, isLoading: pLoading } = useEvolutionProfile();
|
||||||
|
const { data: skills, isLoading: sLoading } = useEvolutionSkills();
|
||||||
|
const { data: stats } = useEvolutionStatistics();
|
||||||
|
const { data: pipeline } = useEvolutionPipelineStatus();
|
||||||
|
const { data: events } = useEvolutionEvents({ limit: 30 });
|
||||||
|
|
||||||
|
const skillList: EvolutionSkill[] = Array.isArray(skills) ? skills : [];
|
||||||
|
// 按 state 分组
|
||||||
|
const grouped = useGroupedSkills(skillList);
|
||||||
|
const isLoading = pLoading || sLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Brain size={20} className="text-primary" />
|
||||||
|
AI 自进化
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
平台从对话与工具调用中持续学习,自动沉淀与迭代你的专属技能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => setRefreshKey((k) => k + 1)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} className={refreshKey ? 'animate-spin' : ''} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key={refreshKey}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* 能力画像 + 流水线状态 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{profile ? (
|
||||||
|
<ProfileCard profile={profile} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-center text-muted-foreground">
|
||||||
|
<Brain size={40} className="mx-auto mb-3 opacity-50" />
|
||||||
|
暂无能力数据,多使用 AI 即可激活自进化
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{pipeline && <PipelineStatusCard status={pipeline} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计 + 事件流 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{stats && <StatisticsCard stats={stats} />}
|
||||||
|
{events && <EventFeed events={events} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 技能图谱 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Network size={18} className="text-primary" />
|
||||||
|
<h3 className="font-semibold text-foreground">技能图谱</h3>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
{skillList.length} 个技能
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skillList.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Network size={44} className="mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">还没有进化出技能,继续与 AI 对话吧</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{grouped.map((g) => (
|
||||||
|
<div key={g.category}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
|
{g.category}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{g.skills.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{g.skills.map((sk) => (
|
||||||
|
<SkillCard key={sk.id} skill={sk} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 category 分组,保持顺序稳定
|
||||||
|
function useGroupedSkills(skills: EvolutionSkill[]): { category: string; skills: EvolutionSkill[] }[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const map = new Map<string, EvolutionSkill[]>();
|
||||||
|
for (const s of skills) {
|
||||||
|
const cat = s.category || '未分类';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(s);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([category, list]) => ({ category, skills: list }));
|
||||||
|
}, [skills]);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
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 { Input } from '@/components/ui/input';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Store,
|
||||||
|
Star,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Globe,
|
||||||
|
Bot,
|
||||||
|
Workflow,
|
||||||
|
Puzzle,
|
||||||
|
ShieldCheck,
|
||||||
|
Crown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useMarketApps,
|
||||||
|
useMarketAds,
|
||||||
|
type MarketAppsParams,
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import type {
|
||||||
|
MarketApp,
|
||||||
|
MarketAppType,
|
||||||
|
MarketAppSource,
|
||||||
|
MarketSort,
|
||||||
|
} from '@/api/client';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<MarketAppType, string> = {
|
||||||
|
standalone: '独立应用',
|
||||||
|
embedded: '嵌入式',
|
||||||
|
skill: '技能',
|
||||||
|
agent: '智能体',
|
||||||
|
workflow: '工作流',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<MarketAppType, typeof Globe> = {
|
||||||
|
standalone: Globe,
|
||||||
|
embedded: Puzzle,
|
||||||
|
skill: Sparkles,
|
||||||
|
agent: Bot,
|
||||||
|
workflow: Workflow,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<MarketAppSource, string> = {
|
||||||
|
official: '官方',
|
||||||
|
community: '社区',
|
||||||
|
third_party: '第三方',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(app: MarketApp): { label: string; free: boolean } {
|
||||||
|
if (app.is_member_free) return { label: '会员免费', free: true };
|
||||||
|
if (app.price > 0) return { label: `¥${app.price}`, free: false };
|
||||||
|
return { label: '免费', free: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debounced, setDebounced] = useState('');
|
||||||
|
const [type, setType] = useState<MarketAppType | undefined>(undefined);
|
||||||
|
const [sort, setSort] = useState<MarketSort>('popular');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
// 简单防抖搜索(300ms)
|
||||||
|
useMemo(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(search.trim()), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const params: MarketAppsParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
type,
|
||||||
|
search: debounced || undefined,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
[type, debounced, sort, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useMarketApps(params);
|
||||||
|
const { data: ads } = useMarketAds();
|
||||||
|
|
||||||
|
const items: MarketApp[] = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const typeFilters: (MarketAppType | undefined)[] = [
|
||||||
|
undefined,
|
||||||
|
'skill',
|
||||||
|
'agent',
|
||||||
|
'workflow',
|
||||||
|
'standalone',
|
||||||
|
'embedded',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Store size={20} className="text-primary" />
|
||||||
|
技能市场
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
浏览、安装官方与社区技能,一键接入平台能力
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 广告位 */}
|
||||||
|
{ads && ads.length > 0 && (
|
||||||
|
<a
|
||||||
|
href={ads[0].url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block rounded-xl overflow-hidden border border-primary/20 bg-gradient-to-r from-primary/10 to-amber-50 dark:to-amber-950/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<Sparkles size={22} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">{ads[0].title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">点击查看详情 →</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索 + 排序 */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="搜索技能、智能体、工作流…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['popular', 'latest', 'rating'] as MarketSort[]).map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
size="sm"
|
||||||
|
variant={sort === s ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setSort(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s === 'popular' ? '最热' : s === 'latest' ? '最新' : '评分'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型筛选 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{typeFilters.map((tf) => (
|
||||||
|
<Button
|
||||||
|
key={tf ?? 'all'}
|
||||||
|
size="sm"
|
||||||
|
variant={type === tf ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setType(tf);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tf ? TYPE_LABELS[tf] : '全部'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<Store size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-lg font-medium mb-1">未找到匹配的技能</p>
|
||||||
|
<p className="text-sm">试试调整搜索词或筛选条件</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
{items.map((app: MarketApp) => {
|
||||||
|
const TypeIcon = TYPE_ICONS[app.type] ?? Sparkles;
|
||||||
|
const price = formatPrice(app);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={app.id}
|
||||||
|
className="hover:shadow-md transition-shadow flex flex-col"
|
||||||
|
>
|
||||||
|
<CardContent className="p-5 flex flex-col flex-1">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="w-11 h-11 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
{app.icon ? (
|
||||||
|
<img
|
||||||
|
src={app.icon}
|
||||||
|
alt={app.name}
|
||||||
|
className="w-7 h-7 rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TypeIcon size={22} className="text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
|
{app.verified && (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
<ShieldCheck size={11} />
|
||||||
|
已认证
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{app.installed ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Check size={11} />
|
||||||
|
已安装
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{SOURCE_LABELS[app.source]}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-foreground truncate">{app.name}</h3>
|
||||||
|
{app.is_member_free && (
|
||||||
|
<Crown size={14} className="text-amber-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
由 {app.publisher_name} 发布
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-4 flex-1">
|
||||||
|
{app.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
|
||||||
|
{app.rating_avg > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star size={13} className="text-amber-500 fill-amber-500" />
|
||||||
|
{app.rating_avg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download size={13} />
|
||||||
|
{formatCount(app.install_count)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
{TYPE_LABELS[app.type]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="mb-3" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
price.free
|
||||||
|
? 'text-sm font-medium text-green-600 dark:text-green-400'
|
||||||
|
: 'text-sm font-semibold text-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{price.label}
|
||||||
|
</span>
|
||||||
|
{app.installed ? (
|
||||||
|
<Button size="sm" variant="outline" disabled className="gap-1">
|
||||||
|
<Check size={14} />
|
||||||
|
已安装
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="gap-1">
|
||||||
|
<Download size={14} />
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1 || isFetching}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages || isFetching}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
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 {
|
||||||
|
Bell,
|
||||||
|
CheckCheck,
|
||||||
|
Check,
|
||||||
|
Circle,
|
||||||
|
Loader2,
|
||||||
|
Filter,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useNotifications,
|
||||||
|
useUnreadCount,
|
||||||
|
useMarkNotificationRead,
|
||||||
|
useMarkAllNotificationsRead,
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import type { NotificationItem } from '@/api/client';
|
||||||
|
|
||||||
|
// 通知类型 → 中文标签(后端 type 透传,未知类型回退「通知」)
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
task: '任务',
|
||||||
|
billing: '账单',
|
||||||
|
system: '系统',
|
||||||
|
evolution: '自进化',
|
||||||
|
market: '市场',
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeLabel(t: string | null): string {
|
||||||
|
if (!t) return '通知';
|
||||||
|
return TYPE_LABELS[t] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - d.getTime();
|
||||||
|
const min = Math.floor(diff / 60000);
|
||||||
|
if (min < 1) return '刚刚';
|
||||||
|
if (min < 60) return `${min} 分钟前`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr} 小时前`;
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
if (day < 7) return `${day} 天前`;
|
||||||
|
return d.toLocaleDateString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const [isRead, setIsRead] = useState<boolean | undefined>(undefined);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useNotifications({
|
||||||
|
is_read: isRead,
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const { data: unread } = useUnreadCount();
|
||||||
|
const markRead = useMarkNotificationRead();
|
||||||
|
const markAllRead = useMarkAllNotificationsRead();
|
||||||
|
|
||||||
|
const items: NotificationItem[] = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const unreadCount = unread?.count ?? 0;
|
||||||
|
|
||||||
|
// 筛选 tabs:全部 / 未读 / 已读
|
||||||
|
const filters: { key: string; label: string; value: boolean | undefined }[] = [
|
||||||
|
{ key: 'all', label: '全部', value: undefined },
|
||||||
|
{ key: 'unread', label: '未读', value: false },
|
||||||
|
{ key: 'read', label: '已读', value: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const busy = markRead.isPending || markAllRead.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Bell size={20} className="text-primary" />
|
||||||
|
消息通知
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge className="bg-primary/10 text-primary border-primary/20 ml-1">
|
||||||
|
{unreadCount} 未读
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
集中查看系统通知、任务动态与配额告警
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={unreadCount === 0 || busy}
|
||||||
|
onClick={() => markAllRead.mutate(undefined)}
|
||||||
|
>
|
||||||
|
<CheckCheck size={15} />
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选 tabs */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Filter size={15} className="text-muted-foreground" />
|
||||||
|
{filters.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.key}
|
||||||
|
size="sm"
|
||||||
|
variant={isRead === f.value ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setIsRead(f.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<Bell size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-lg font-medium mb-1">暂无通知</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{isRead === false ? '所有通知都已读啦' : '系统有动态时会在这里提醒你'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{items.map((n: NotificationItem) => (
|
||||||
|
<Card
|
||||||
|
key={n.id}
|
||||||
|
className={
|
||||||
|
'transition-colors ' +
|
||||||
|
(n.is_read ? 'bg-card' : 'bg-primary/5 border-primary/20')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
|
{/* 已读/未读指示 */}
|
||||||
|
<div className="mt-1 shrink-0">
|
||||||
|
{n.is_read ? (
|
||||||
|
<Check size={16} className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Circle size={10} className="fill-primary text-primary mt-1.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{typeLabel(n.type)}
|
||||||
|
</Badge>
|
||||||
|
<h3 className="font-medium text-foreground truncate">
|
||||||
|
{n.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||||
|
{formatTime(n.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{n.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!n.is_read && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 gap-1 text-primary hover:text-primary"
|
||||||
|
disabled={markRead.isPending}
|
||||||
|
onClick={() => markRead.mutate(n.id)}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
标为已读
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1 || isFetching}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages || isFetching}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,208 @@
|
|||||||
|
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';
|
||||||
|
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||||
|
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||||
|
import { MarketPage } from '@/pages/front/MarketPage';
|
||||||
|
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||||
|
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||||
|
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Puzzle,
|
||||||
|
Workflow,
|
||||||
|
Shield,
|
||||||
|
Radio,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useCurrentUser } from '@/hooks/useApi';
|
||||||
|
import { AdminLayout } from '@/pages/admin/AdminLayout';
|
||||||
|
import { AdminDashboard } from '@/pages/admin/AdminDashboard';
|
||||||
|
import {
|
||||||
|
UsersPage,
|
||||||
|
RolesPage,
|
||||||
|
TenantPage,
|
||||||
|
ModelsPage,
|
||||||
|
KnowledgeAdminPage,
|
||||||
|
AssetsPage,
|
||||||
|
StoragePage,
|
||||||
|
AgentsAdminPage,
|
||||||
|
McpPage,
|
||||||
|
McpMarketPage,
|
||||||
|
SkillsAdminPage,
|
||||||
|
WorkflowAdminPage,
|
||||||
|
AutomationPage,
|
||||||
|
AppsAdminPage,
|
||||||
|
CodeExecPage,
|
||||||
|
DistributionsPage,
|
||||||
|
AdsPage,
|
||||||
|
MarketAdminPage,
|
||||||
|
PaymentPage,
|
||||||
|
BillingPage,
|
||||||
|
MembershipPage,
|
||||||
|
ServicesPage,
|
||||||
|
LogsPage,
|
||||||
|
DeveloperPage,
|
||||||
|
AnalyticsPage,
|
||||||
|
} from '@/pages/admin/AdminPlaceholders';
|
||||||
|
|
||||||
|
// 管理员守卫:复用 frontend-v2 的 auth_token 认证链,role=admin|super_admin 放行。
|
||||||
|
function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: user, isLoading } = useCurrentUser();
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">加载中…</div>;
|
||||||
|
}
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
if (!isAdmin) return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 />} />
|
||||||
|
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
|
||||||
|
<Route path="market" element={<MarketPage />} />
|
||||||
|
<Route
|
||||||
|
path="agents"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="Agent 智能体"
|
||||||
|
icon={Bot}
|
||||||
|
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
|
||||||
|
features={[
|
||||||
|
'拖拽式 Agent 编排画布',
|
||||||
|
'多 Agent 协作与对话路由',
|
||||||
|
'绑定知识库与 MCP 工具',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="mcp"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="MCP 工具"
|
||||||
|
icon={Puzzle}
|
||||||
|
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
|
||||||
|
features={[
|
||||||
|
'粘贴 MCP 配置一键连接',
|
||||||
|
'工具列表与连通性测试',
|
||||||
|
'官方与社区 MCP 应用市场',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="workflows"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="工作流"
|
||||||
|
icon={Workflow}
|
||||||
|
description="用可视化 DAG 编排多步骤自动化流程。"
|
||||||
|
features={[
|
||||||
|
'节点拖拽编排',
|
||||||
|
'条件分支与并行执行',
|
||||||
|
'可导出可移植的 YAML 规范',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="rules"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="规则库"
|
||||||
|
icon={Shield}
|
||||||
|
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
|
||||||
|
features={[
|
||||||
|
'工具调用四级风险审批',
|
||||||
|
'Loop 原语与验证器',
|
||||||
|
'企业级合规策略模板',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="channels"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="渠道"
|
||||||
|
icon={Radio}
|
||||||
|
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
|
||||||
|
features={[
|
||||||
|
'微信公众号 / 个人微信接入',
|
||||||
|
'IM Bot 多端统一适配',
|
||||||
|
'渠道级权限与配额',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="evolution" element={<EvolutionPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin 后台:管理员守卫 + AdminLayout 外壳 + 嵌套二级路由 */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<AdminGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AdminGuard>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
<Route path="roles" element={<RolesPage />} />
|
||||||
|
<Route path="tenant" element={<TenantPage />} />
|
||||||
|
<Route path="models" element={<ModelsPage />} />
|
||||||
|
<Route path="knowledge" element={<KnowledgeAdminPage />} />
|
||||||
|
<Route path="assets" element={<AssetsPage />} />
|
||||||
|
<Route path="storage" element={<StoragePage />} />
|
||||||
|
<Route path="agents" element={<AgentsAdminPage />} />
|
||||||
|
<Route path="mcp" element={<McpPage />} />
|
||||||
|
<Route path="mcp-market" element={<McpMarketPage />} />
|
||||||
|
<Route path="skills" element={<SkillsAdminPage />} />
|
||||||
|
<Route path="workflow" element={<WorkflowAdminPage />} />
|
||||||
|
<Route path="automation" element={<AutomationPage />} />
|
||||||
|
<Route path="apps" element={<AppsAdminPage />} />
|
||||||
|
<Route path="code-exec" element={<CodeExecPage />} />
|
||||||
|
<Route path="distributions" element={<DistributionsPage />} />
|
||||||
|
<Route path="ads" element={<AdsPage />} />
|
||||||
|
<Route path="market" element={<MarketAdminPage />} />
|
||||||
|
<Route path="payment" element={<PaymentPage />} />
|
||||||
|
<Route path="billing" element={<BillingPage />} />
|
||||||
|
<Route path="membership" element={<MembershipPage />} />
|
||||||
|
<Route path="services" element={<ServicesPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
<Route path="developer" element={<DeveloperPage />} />
|
||||||
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend responses use a unified envelope `{code, data, message}`.
|
||||||
|
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
|
||||||
|
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||||
|
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 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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; role?: string }>('/auth/me'),
|
||||||
|
|
||||||
|
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||||
|
|
||||||
|
sendCode: (data: { email: string }) =>
|
||||||
|
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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 {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model_id: string;
|
||||||
|
status?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
source?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
message_count?: number; // 后端未返回,前端可选兼容
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model_id: string;
|
||||||
|
created_at: string;
|
||||||
|
token_used?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatApi = {
|
||||||
|
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||||
|
|
||||||
|
createConversation: (payload: { title?: string; model?: string }) =>
|
||||||
|
data<Conversation>('/chat/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteConversation: (id: string) =>
|
||||||
|
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getMessages: (convId: string) =>
|
||||||
|
data<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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 后端契约 (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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
document_count: number;
|
||||||
|
status: 'active' | 'building' | 'error' | string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseList {
|
||||||
|
bases: KnowledgeBase[];
|
||||||
|
storage_used: number;
|
||||||
|
storage_limit: number;
|
||||||
|
membership_level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||||
|
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',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
||||||
|
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
|
||||||
|
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
|
||||||
|
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
|
||||||
|
|
||||||
|
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
|
||||||
|
export type MarketAppSource = 'official' | 'community' | 'third_party';
|
||||||
|
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
|
||||||
|
|
||||||
|
export interface MarketApp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string | null;
|
||||||
|
type: MarketAppType;
|
||||||
|
source: MarketAppSource;
|
||||||
|
publisher_id: string;
|
||||||
|
publisher_name: string;
|
||||||
|
is_member_free: boolean;
|
||||||
|
join_promotion: boolean;
|
||||||
|
status: MarketAppStatus;
|
||||||
|
price: number;
|
||||||
|
install_count: number;
|
||||||
|
rating_avg: number;
|
||||||
|
review_count: number;
|
||||||
|
created_at: string;
|
||||||
|
capability: string | null;
|
||||||
|
category: string | null;
|
||||||
|
tags: string[];
|
||||||
|
installed: boolean;
|
||||||
|
version: string | null;
|
||||||
|
security_scan: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
dependencies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
|
||||||
|
export interface MarketAppDetail extends MarketApp {
|
||||||
|
long_description?: string;
|
||||||
|
screenshots?: string[];
|
||||||
|
homepage_url?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReview {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReviewList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: MarketReview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketAd {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image_url: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketAppList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: MarketApp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||||
|
listApps: (params: {
|
||||||
|
type?: MarketAppType;
|
||||||
|
source?: MarketAppSource;
|
||||||
|
capability?: string;
|
||||||
|
search?: string;
|
||||||
|
sort?: MarketSort;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.type) qs.set('type', params.type);
|
||||||
|
if (params.source) qs.set('source', params.source);
|
||||||
|
if (params.capability) qs.set('capability', params.capability);
|
||||||
|
if (params.search) qs.set('search', params.search);
|
||||||
|
if (params.sort) qs.set('sort', params.sort);
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getApp: (appId: string) =>
|
||||||
|
data<MarketAppDetail>(`/apps/${appId}`),
|
||||||
|
|
||||||
|
getMyApps: () =>
|
||||||
|
data<MyApp[]>('/my-apps'),
|
||||||
|
|
||||||
|
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAds: () =>
|
||||||
|
data<MarketAd[]>('/ads'),
|
||||||
|
|
||||||
|
// ─── 写操作(后续迭代启用)───
|
||||||
|
installApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/install`, { method: 'POST' }),
|
||||||
|
|
||||||
|
uninstallApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
purchaseApp: (appId: string) =>
|
||||||
|
request(`/apps/${appId}/purchase`, { method: 'POST' }),
|
||||||
|
|
||||||
|
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
|
||||||
|
request(`/apps/${appId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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 {
|
||||||
|
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||||
|
ilink_bot_id: string | null;
|
||||||
|
connected_at: string | null;
|
||||||
|
last_active?: string | null;
|
||||||
|
message_count?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
baseurl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imBotApi = {
|
||||||
|
getStatus: () => successData<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 MembershipInfo {
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
status: string;
|
||||||
|
start_at: string;
|
||||||
|
expire_at: string;
|
||||||
|
is_expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
avatar: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
membership: MembershipInfo | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogItem {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
user_agent: string;
|
||||||
|
login_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: LoginLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_preview: string;
|
||||||
|
status: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
expire_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
|
||||||
|
export interface ApiKeyCreated {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api_key: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
|
||||||
|
|
||||||
|
export interface PurchasedAppItem {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_icon: string;
|
||||||
|
app_type: string;
|
||||||
|
price: number;
|
||||||
|
purchased_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileApi = {
|
||||||
|
get: () => data<UserProfile>('/profile/'),
|
||||||
|
|
||||||
|
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
|
||||||
|
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
|
getBalance: () => data<number>('/profile/balance'),
|
||||||
|
|
||||||
|
getMembership: () => data<{
|
||||||
|
has_membership: boolean;
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
status: string;
|
||||||
|
start_at: string;
|
||||||
|
expire_at: string;
|
||||||
|
is_expired: boolean;
|
||||||
|
}>('/profile/membership'),
|
||||||
|
|
||||||
|
getLoginLogs: (page = 1, page_size = 10) =>
|
||||||
|
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
|
||||||
|
|
||||||
|
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||||
|
|
||||||
|
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
|
||||||
|
|
||||||
|
// ─── 写操作 ───
|
||||||
|
|
||||||
|
updatePassword: (payload: { old_password: string; new_password: string }) =>
|
||||||
|
request<{ code: number; message: string }>('/profile/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
createApiKey: (name: string) =>
|
||||||
|
data<ApiKeyCreated>('/profile/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
|
||||||
|
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TokenOverview {
|
||||||
|
total_used: number;
|
||||||
|
balance: number;
|
||||||
|
today_apps: number;
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRanking {
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
usage_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopApp {
|
||||||
|
conversation_id: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenRankings {
|
||||||
|
model_rankings: ModelRanking[];
|
||||||
|
top_apps: TopApp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenLogItem {
|
||||||
|
id: string;
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
conversation_id: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenLogPage {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: TokenLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBalance {
|
||||||
|
balance: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokenApi = {
|
||||||
|
getOverview: () => data<TokenOverview>('/token/overview'),
|
||||||
|
|
||||||
|
getRankings: () => data<TokenRankings>('/token/rankings'),
|
||||||
|
|
||||||
|
getLogs: (params: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
model_id?: string;
|
||||||
|
app_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.start_date) qs.set('start_date', params.start_date);
|
||||||
|
if (params.end_date) qs.set('end_date', params.end_date);
|
||||||
|
if (params.model_id) qs.set('model_id', params.model_id);
|
||||||
|
if (params.app_type) qs.set('app_type', params.app_type);
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||||
|
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||||
|
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||||
|
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||||
|
// GET /unread-count → {code,data:{count},message}
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
type: string | null;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
is_read: boolean;
|
||||||
|
related_id: string | null;
|
||||||
|
related_type: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationList {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: NotificationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.type) qs.set('type', params.type);
|
||||||
|
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead: (id: string) =>
|
||||||
|
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||||
|
|
||||||
|
markAllRead: (type?: string) =>
|
||||||
|
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||||
|
|
||||||
|
unreadCount: () =>
|
||||||
|
data<{ count: number }>('/notifications/unread-count'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Admin 后台 ──────────────────────────────────────────────────────────────────
|
||||||
|
// 后端契约 (app/api/admin/*):绝大多数端点返回统一信封 {code,data,message},
|
||||||
|
// 但 dashboard.py 的 `/admin/dashboard/stats` 返回**裸 dict**(无 code/data 包裹),
|
||||||
|
// `/admin/dashboard/daily-stats` 返回 {code,data:{days,data}}。
|
||||||
|
// 因此 adminApi 不复用 data<T>(),改用 rawAdmin<T>() 解包并按端点分别处理 envelope。
|
||||||
|
|
||||||
|
export interface AdminDashboardStats {
|
||||||
|
total_users: number;
|
||||||
|
active_users: number;
|
||||||
|
total_models: number;
|
||||||
|
enabled_models: number;
|
||||||
|
total_conversations: number;
|
||||||
|
active_conversations: number;
|
||||||
|
valid_conversations: number;
|
||||||
|
total_knowledge_bases: number;
|
||||||
|
total_documents: number;
|
||||||
|
today_orders: number;
|
||||||
|
today_subscription_orders: number;
|
||||||
|
today_tokens: number;
|
||||||
|
week_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
today_conversations: number;
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDailyStat {
|
||||||
|
date: string;
|
||||||
|
tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDailyStats {
|
||||||
|
days: string[];
|
||||||
|
data: AdminDailyStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一信封解包(与 data<T>() 相同语义,但显式标记 admin 域,便于后续差异演进)
|
||||||
|
async function adminData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||||
|
return env.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
// /stats 裸 dict(无 code/data 包裹)→ 走 raw request,直接返回 json
|
||||||
|
getStats: () => request<AdminDashboardStats>('/admin/dashboard/stats'),
|
||||||
|
|
||||||
|
// /daily-stats 标准信封 {code,data:{days,data}}
|
||||||
|
getDailyStats: () => adminData<AdminDailyStats>('/admin/dashboard/daily-stats'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||||
|
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||||
|
// 技能状态 state: main | staging | baby | frozen
|
||||||
|
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||||
|
|
||||||
|
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||||
|
|
||||||
|
export interface EvolutionSkill {
|
||||||
|
id: string;
|
||||||
|
skill_name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
state: EvolutionSkillState;
|
||||||
|
use_count: number;
|
||||||
|
version: number;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
skill_md_content?: string | null; // 仅 detail 返回
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionEvent {
|
||||||
|
id: string;
|
||||||
|
skill_name: string;
|
||||||
|
event_type: string;
|
||||||
|
description: string;
|
||||||
|
version_before: number | null;
|
||||||
|
version_after: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionTrajectory {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
file_path: string | null;
|
||||||
|
status: string;
|
||||||
|
atom_count: number | null;
|
||||||
|
token_used: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
processed_at: string | null;
|
||||||
|
weightscore?: number;
|
||||||
|
atom_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionSkillTree {
|
||||||
|
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||||
|
total_count: number;
|
||||||
|
main_count: number;
|
||||||
|
baby_count: number;
|
||||||
|
staging_count: number;
|
||||||
|
frozen_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionStatistics {
|
||||||
|
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||||
|
total_use_count: number;
|
||||||
|
category_distribution: { category: string; count: number }[];
|
||||||
|
daily_trend: { date: string; count: number }[];
|
||||||
|
low_use_skills: EvolutionSkill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionDimension {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
maxValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionProfile {
|
||||||
|
level: number;
|
||||||
|
level_title: string;
|
||||||
|
total_skills: number;
|
||||||
|
active_skills: number;
|
||||||
|
staging_skills: number;
|
||||||
|
baby_skills: number;
|
||||||
|
total_use_count: number;
|
||||||
|
dimensions: EvolutionDimension[];
|
||||||
|
tags: string[];
|
||||||
|
growth_suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionPipelineStatus {
|
||||||
|
scheduler_running: boolean;
|
||||||
|
watcher_status: string;
|
||||||
|
pipeline_status: string;
|
||||||
|
discovered_count: number;
|
||||||
|
done_count: number;
|
||||||
|
total_skills: number;
|
||||||
|
total_trajectories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionWatcherStatus {
|
||||||
|
user_id: string;
|
||||||
|
scheduler_running: boolean;
|
||||||
|
watcher_status: string;
|
||||||
|
pipeline_status: string;
|
||||||
|
pipeline_processed: number;
|
||||||
|
watcher_error: string | null;
|
||||||
|
skill_count: number;
|
||||||
|
trajectory_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionReport {
|
||||||
|
id: string;
|
||||||
|
report_type: string;
|
||||||
|
period_start: string | null;
|
||||||
|
period_end: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionReportDetail extends EvolutionReport {
|
||||||
|
user_id: string;
|
||||||
|
report_data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evolutionApi = {
|
||||||
|
// 技能管理
|
||||||
|
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.state) qs.set('state', params.state);
|
||||||
|
if (params.keyword) qs.set('keyword', params.keyword);
|
||||||
|
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||||
|
if (params.order) qs.set('order', params.order);
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSkillDetail: (skillName: string) =>
|
||||||
|
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||||
|
|
||||||
|
// 进化事件
|
||||||
|
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.event_type) qs.set('event_type', params.event_type);
|
||||||
|
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 驾驶舱
|
||||||
|
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||||
|
getTimeline: (event_type?: string) =>
|
||||||
|
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||||
|
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||||
|
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||||
|
|
||||||
|
// 流水线 / Watcher
|
||||||
|
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||||
|
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||||
|
|
||||||
|
// 报告
|
||||||
|
listReports: (report_type?: string) =>
|
||||||
|
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||||
|
getReportDetail: (reportId: string) =>
|
||||||
|
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Sparkles, Construction } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ComingSoonProps {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description?: string;
|
||||||
|
features?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一「页面开发中」占位页(amber 金暖白设计语言)。
|
||||||
|
* 用于前台尚未实现真实功能的路由,提供有品牌感的「敬请期待」状态,
|
||||||
|
* 而非空白或纯文字提示,保证导航点击不出现断崖式空页面。
|
||||||
|
*/
|
||||||
|
export function ComingSoon({ title, icon: Icon, description, features }: ComingSoonProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh] px-4">
|
||||||
|
<Card className="w-full max-w-lg text-center border-border">
|
||||||
|
<CardContent className="p-10">
|
||||||
|
{/* 图标芯片 */}
|
||||||
|
<div className="mx-auto mb-6 w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon size={30} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
|
||||||
|
<Badge className="bg-primary/10 text-primary border-primary/20 hover:bg-primary/10">
|
||||||
|
<Construction size={12} className="mr-1" />
|
||||||
|
开发中
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-6">
|
||||||
|
{description ?? `${title} 正在紧张开发中,敬请期待。上线后这里将提供完整的功能体验。`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{features && features.length > 0 && (
|
||||||
|
<div className="text-left space-y-2 mb-6">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Sparkles size={15} className="text-primary mt-0.5 shrink-0" />
|
||||||
|
<span>{f}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground/70 bg-muted rounded-full px-3 py-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||||
|
我们将尽快为您带来 {title}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,312 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
authApi,
|
||||||
|
chatApi,
|
||||||
|
knowledgeApi,
|
||||||
|
marketApi,
|
||||||
|
imBotApi,
|
||||||
|
profileApi,
|
||||||
|
tokenApi,
|
||||||
|
type TokenOverview,
|
||||||
|
} 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: unknown) => asArray(d),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMyApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market', 'my-apps'],
|
||||||
|
queryFn: marketApi.getMyApps,
|
||||||
|
select: (d: unknown) => 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: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileBalance() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'balance'],
|
||||||
|
queryFn: profileApi.getBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileMembership() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'membership'],
|
||||||
|
queryFn: profileApi.getMembership,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileLoginLogs(page = 1, page_size = 10) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'login-logs', page, page_size],
|
||||||
|
queryFn: () => profileApi.getLoginLogs(page, page_size),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileApiKeys() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'api-keys'],
|
||||||
|
queryFn: profileApi.getApiKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfilePurchasedApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profile', 'purchased-apps'],
|
||||||
|
queryFn: profileApi.getPurchasedApps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateProfilePassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { old_password: string; new_password: string }) =>
|
||||||
|
profileApi.updatePassword(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApiKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => profileApi.createApiKey(name),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateApiKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
|
||||||
|
profileApi.updateApiKey(keyId, status),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||||
|
// so the homepage never ships fake/mock values.
|
||||||
|
export function useDashboardStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'overview'],
|
||||||
|
queryFn: tokenApi.getOverview,
|
||||||
|
select: (d: TokenOverview) => ({
|
||||||
|
active_users: d.today_apps,
|
||||||
|
today_conversations: d.today_apps,
|
||||||
|
skill_count: d.total_used,
|
||||||
|
revenue: d.balance,
|
||||||
|
user_change: '',
|
||||||
|
conv_change: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenOverview() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'overview'],
|
||||||
|
queryFn: tokenApi.getOverview,
|
||||||
|
refetchInterval: 60000, // 每分钟刷新一次概览
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenRankings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'rankings'],
|
||||||
|
queryFn: tokenApi.getRankings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenBalance() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'balance'],
|
||||||
|
queryFn: tokenApi.getBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenLogs(params: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
model_id?: string;
|
||||||
|
app_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['token', 'logs', params],
|
||||||
|
queryFn: () => tokenApi.getLogs(params),
|
||||||
|
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Sparkles, Construction } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface AdminComingSoonProps {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description?: string;
|
||||||
|
features?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* admin 后台统一「模块开发中」占位页(amber 金暖白)。
|
||||||
|
* 旧 admin 共 33 个二级模块,初期仅 Dashboard 为真实数据页,
|
||||||
|
* 其余模块用此占位,保证后台导航点击不出现空白断崖。
|
||||||
|
*/
|
||||||
|
export function AdminComingSoon({ title, icon: Icon, description, features }: AdminComingSoonProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh] px-4">
|
||||||
|
<Card className="w-full max-w-lg text-center border-border">
|
||||||
|
<CardContent className="p-10">
|
||||||
|
<div className="mx-auto mb-6 w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon size={30} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
|
||||||
|
<Badge className="bg-primary/10 text-primary border-primary/20 hover:bg-primary/10">
|
||||||
|
<Construction size={12} className="mr-1" />
|
||||||
|
建设中
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-6">
|
||||||
|
{description ?? `${title} 模块正在开发中,敬请期待。上线后这里将提供完整的后台管理能力。`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{features && features.length > 0 && (
|
||||||
|
<div className="text-left space-y-2 mb-6">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
|
<Sparkles size={15} className="text-primary mt-0.5 shrink-0" />
|
||||||
|
<span>{f}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground/70 bg-muted rounded-full px-3 py-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||||
|
我们将尽快为您带来 {title}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi, type AdminDailyStat } from '@/api/client';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useCurrentUser } from '@/hooks/useApi';
|
||||||
|
import { Users, Cpu, MessageSquare, BookOpen, Coins, Activity, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
function formatNumber(n: number | undefined): string {
|
||||||
|
if (n === undefined || n === null) return '—';
|
||||||
|
return n.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBjTime(iso: string | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
// 北京时间 = UTC+8
|
||||||
|
const bj = new Date(d.getTime() + 8 * 3600 * 1000);
|
||||||
|
const p = (x: number) => String(x).padStart(2, '0');
|
||||||
|
return `${bj.getUTCFullYear()}-${p(bj.getUTCMonth() + 1)}-${p(bj.getUTCDate())} ${p(bj.getUTCHours())}:${p(bj.getUTCMinutes())}:${p(bj.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
{Icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-foreground">{value}</div>
|
||||||
|
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简易柱状图(纯 SVG,amber 金暖白)
|
||||||
|
function TokenBarChart({ data }: { data: AdminDailyStat[] }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="text-sm text-muted-foreground py-8 text-center">暂无近 7 日 Token 数据</div>;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data.map((d) => d.tokens), 1);
|
||||||
|
const width = 560;
|
||||||
|
const height = 180;
|
||||||
|
const gap = 16;
|
||||||
|
const barW = (width - gap * (data.length - 1)) / data.length;
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto">
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = Math.max((d.tokens / max) * (height - 30), 2);
|
||||||
|
const x = i * (barW + gap);
|
||||||
|
const y = height - h - 20;
|
||||||
|
return (
|
||||||
|
<g key={d.date}>
|
||||||
|
<rect x={x} y={y} width={barW} height={h} rx={4} fill="var(--primary)" opacity={0.85} />
|
||||||
|
<text x={x + barW / 2} y={height - 6} textAnchor="middle" fontSize="10" fill="var(--muted-foreground)">
|
||||||
|
{d.date.slice(5)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniDonut({ enabled, total }: { enabled: number; total: number }) {
|
||||||
|
const pct = total > 0 ? enabled / total : 0;
|
||||||
|
const r = 26;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 72" className="w-16 h-16">
|
||||||
|
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--border)" strokeWidth="8" />
|
||||||
|
<circle
|
||||||
|
cx="36"
|
||||||
|
cy="36"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeDasharray={`${c * pct} ${c}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 36 36)"
|
||||||
|
/>
|
||||||
|
<text x="36" y="40" textAnchor="middle" fontSize="14" fontWeight="700" fill="var(--foreground)">
|
||||||
|
{Math.round(pct * 100)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDashboard() {
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
const statsQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'dashboard', 'stats'],
|
||||||
|
queryFn: adminApi.getStats,
|
||||||
|
});
|
||||||
|
const dailyQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'dashboard', 'daily-stats'],
|
||||||
|
queryFn: adminApi.getDailyStats,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
const daily = dailyQuery.data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 页头 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">数据看板</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
欢迎回来,{user?.username || '管理员'}。平台实时运营概览。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-muted-foreground">服务器时间(北京)</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{formatBjTime(stats?.server_time)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误态 */}
|
||||||
|
{statsQuery.isError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 text-destructive px-4 py-3 text-sm">
|
||||||
|
看板数据加载失败:{(statsQuery.error as Error)?.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计卡片 6 张 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard icon={<Users size={18} />} label="总用户数" value={formatNumber(stats?.total_users)} sub={`活跃 ${formatNumber(stats?.active_users)}`} />
|
||||||
|
<StatCard icon={<Cpu size={18} />} label="模型总数" value={formatNumber(stats?.total_models)} sub={`启用 ${formatNumber(stats?.enabled_models)}`} />
|
||||||
|
<StatCard icon={<MessageSquare size={18} />} label="对话总数" value={formatNumber(stats?.total_conversations)} sub={`今日 ${formatNumber(stats?.today_conversations)}`} />
|
||||||
|
<StatCard icon={<BookOpen size={18} />} label="知识库" value={formatNumber(stats?.total_knowledge_bases)} sub={`文档 ${formatNumber(stats?.total_documents)}`} />
|
||||||
|
<StatCard icon={<Coins size={18} />} label="累计 Token" value={formatNumber(stats?.total_tokens)} sub={`今日 ${formatNumber(stats?.today_tokens)}`} />
|
||||||
|
<StatCard icon={<Activity size={18} />} label="今日订单" value={formatNumber(stats?.today_orders)} sub={`订阅 ${formatNumber(stats?.today_subscription_orders)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图表区 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card className="border-border lg:col-span-2">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TrendingUp size={18} className="text-primary" />
|
||||||
|
<h2 className="text-base font-semibold text-foreground">近 7 日 Token 消耗</h2>
|
||||||
|
</div>
|
||||||
|
{dailyQuery.isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-8 text-center">加载中…</div>
|
||||||
|
) : (
|
||||||
|
<TokenBarChart data={daily} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h2 className="text-base font-semibold text-foreground mb-4">模型启用率</h2>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<MiniDonut enabled={stats?.enabled_models ?? 0} total={stats?.total_models ?? 0} />
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
启用 {formatNumber(stats?.enabled_models)} / 共 {formatNumber(stats?.total_models)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useCurrentUser } from '@/hooks/useApi';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Cpu,
|
||||||
|
CreditCard,
|
||||||
|
Boxes,
|
||||||
|
Crown,
|
||||||
|
ScrollText,
|
||||||
|
Zap,
|
||||||
|
Bot,
|
||||||
|
Puzzle,
|
||||||
|
BookOpen,
|
||||||
|
FileBox,
|
||||||
|
HardDrive,
|
||||||
|
Workflow,
|
||||||
|
Code2,
|
||||||
|
AppWindow,
|
||||||
|
TerminalSquare,
|
||||||
|
Share2,
|
||||||
|
Megaphone,
|
||||||
|
ShoppingBag,
|
||||||
|
Receipt,
|
||||||
|
Building2,
|
||||||
|
BarChart3,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
LogOut,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface AdminNavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
implemented?: boolean; // true = 真实数据页;false = ComingSoon 占位
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminNavGroup {
|
||||||
|
title: string;
|
||||||
|
items: AdminNavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航结构对照旧 admin(AdminLayout.tsx 6 分组 33 项),
|
||||||
|
// 初期仅 Dashboard 为真实数据页,其余以 ComingSoon 占位,保持导航不出现断崖。
|
||||||
|
const adminNavGroups: AdminNavGroup[] = [
|
||||||
|
{
|
||||||
|
title: '概览',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin', label: '数据看板', icon: <LayoutDashboard size={20} />, implemented: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户与权限',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/users', label: '用户管理', icon: <Users size={20} /> },
|
||||||
|
{ path: '/admin/roles', label: '角色权限', icon: <ShieldCheck size={20} /> },
|
||||||
|
{ path: '/admin/tenant', label: '租户管理', icon: <Building2 size={20} /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模型与资源',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/models', label: '模型管理', icon: <Cpu size={20} /> },
|
||||||
|
{ path: '/admin/knowledge', label: '知识库', icon: <BookOpen size={20} /> },
|
||||||
|
{ path: '/admin/assets', label: '素材管理', icon: <FileBox size={20} /> },
|
||||||
|
{ path: '/admin/storage', label: '存储管理', icon: <HardDrive size={20} /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '智能体与工具',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/agents', label: '智能体', icon: <Bot size={20} /> },
|
||||||
|
{ path: '/admin/mcp', label: 'MCP 工具', icon: <Puzzle size={20} /> },
|
||||||
|
{ path: '/admin/mcp-market', label: 'MCP 市场', icon: <Puzzle size={20} /> },
|
||||||
|
{ path: '/admin/skills', label: '技能管理', icon: <Zap size={20} /> },
|
||||||
|
{ path: '/admin/workflow', label: '工作流', icon: <Workflow size={20} /> },
|
||||||
|
{ path: '/admin/automation', label: '自动化', icon: <TerminalSquare size={20} /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应用与分发',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/apps', label: '应用管理', icon: <AppWindow size={20} /> },
|
||||||
|
{ path: '/admin/code-exec', label: '代码执行', icon: <Code2 size={20} /> },
|
||||||
|
{ path: '/admin/distributions', label: '分发渠道', icon: <Share2 size={20} /> },
|
||||||
|
{ path: '/admin/ads', label: '广告管理', icon: <Megaphone size={20} /> },
|
||||||
|
{ path: '/admin/market', label: '应用市场', icon: <ShoppingBag size={20} /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '运营与计费',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/payment', label: '支付配置', icon: <CreditCard size={20} /> },
|
||||||
|
{ path: '/admin/billing', label: '账单计费', icon: <Receipt size={20} /> },
|
||||||
|
{ path: '/admin/membership', label: '会员体系', icon: <Crown size={20} /> },
|
||||||
|
{ path: '/admin/services', label: '增值服务', icon: <Boxes size={20} /> },
|
||||||
|
{ path: '/admin/logs', label: '日志审计', icon: <ScrollText size={20} /> },
|
||||||
|
{ path: '/admin/developer', label: '开发者', icon: <Code2 size={20} /> },
|
||||||
|
{ path: '/admin/analytics', label: '数据分析', icon: <BarChart3 size={20} /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<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-[248px]'
|
||||||
|
)}>
|
||||||
|
{/* 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-base 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>
|
||||||
|
|
||||||
|
{/* 导航 */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-4 px-2">
|
||||||
|
{adminNavGroups.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}
|
||||||
|
end={item.path === '/admin'}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 底部:访问前台 + 用户信息 + 退出 */}
|
||||||
|
<div className="border-t border-border py-2 px-2 space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors w-full"
|
||||||
|
title={collapsed ? '访问前台' : undefined}
|
||||||
|
>
|
||||||
|
<ExternalLink size={20} />
|
||||||
|
{!collapsed && <span>访问前台</span>}
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>{(user?.username || 'A').slice(0, 1).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">{user?.username || '管理员'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{user?.role || 'admin'}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 主区域 */}
|
||||||
|
<div className={cn('flex-1 flex flex-col min-h-screen', collapsed ? 'ml-[68px]' : 'ml-[248px]')}>
|
||||||
|
<main className="flex-1 p-6 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { AdminComingSoon } from './AdminComingSoon';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
ShieldCheck,
|
||||||
|
Building2,
|
||||||
|
Cpu,
|
||||||
|
BookOpen,
|
||||||
|
FileBox,
|
||||||
|
HardDrive,
|
||||||
|
Bot,
|
||||||
|
Puzzle,
|
||||||
|
Zap,
|
||||||
|
Workflow,
|
||||||
|
TerminalSquare,
|
||||||
|
AppWindow,
|
||||||
|
Code2,
|
||||||
|
Share2,
|
||||||
|
Megaphone,
|
||||||
|
ShoppingBag,
|
||||||
|
CreditCard,
|
||||||
|
Receipt,
|
||||||
|
Crown,
|
||||||
|
Boxes,
|
||||||
|
ScrollText,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* admin 后台占位页集中导出。
|
||||||
|
* 旧 AdminLayout 共 33 个二级模块,初期除 Dashboard(真实数据页)外,
|
||||||
|
* 其余 32 个模块统一用 AdminComingSoon 占位,待后续阶段逐个迁移真实数据。
|
||||||
|
* 每个导出对应一条侧边栏导航路由。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="用户管理"
|
||||||
|
icon={Users}
|
||||||
|
description="管理全平台注册用户,查看活跃度、封禁状态与归属租户。"
|
||||||
|
features={['用户列表与搜索', '角色与状态管理', '登录日志审计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="角色权限"
|
||||||
|
icon={ShieldCheck}
|
||||||
|
description="配置 RBAC 角色与细粒度权限策略,管控后台访问边界。"
|
||||||
|
features={['角色 CRUD', '权限点分配', '资源级授权']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="租户管理"
|
||||||
|
icon={Building2}
|
||||||
|
description="管理多租户隔离、配额与租户级配置。"
|
||||||
|
features={['租户列表', '配额调整', '隔离策略']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="模型管理"
|
||||||
|
icon={Cpu}
|
||||||
|
description="接入与编排 LLM 供应商、模型上下线与计费路由。"
|
||||||
|
features={['供应商配置', '模型启停', '路由权重']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="知识库管理"
|
||||||
|
icon={BookOpen}
|
||||||
|
description="平台级知识库与向量检索策略管理。"
|
||||||
|
features={['知识库列表', '检索调优', '容量监控']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="素材管理"
|
||||||
|
icon={FileBox}
|
||||||
|
description="管理上传的文档、图片与多模态素材资源。"
|
||||||
|
features={['素材库', '分类标签', '占用统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoragePage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="存储管理"
|
||||||
|
icon={HardDrive}
|
||||||
|
description="对象存储桶、配额与冷热分层策略。"
|
||||||
|
features={['存储桶', '配额告警', '生命周期']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="智能体管理"
|
||||||
|
icon={Bot}
|
||||||
|
description="平台级 Agent 编排模板与市场审核。"
|
||||||
|
features={['Agent 模板', '发布审核', '调用统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="MCP 工具"
|
||||||
|
icon={Puzzle}
|
||||||
|
description="管理 Model Context Protocol 工具接入与连通性。"
|
||||||
|
features={['工具配置', '连通测试', '权限治理']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpMarketPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="MCP 市场"
|
||||||
|
icon={Puzzle}
|
||||||
|
description="官方与社区 MCP 应用市场运营。"
|
||||||
|
features={['上架审核', '分类运营', '下载统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="技能管理"
|
||||||
|
icon={Zap}
|
||||||
|
description="AI 自进化技能库的状态与质量治理。"
|
||||||
|
features={['技能状态', '质量评分', '冻结/解冻']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="工作流管理"
|
||||||
|
icon={Workflow}
|
||||||
|
description="可视化 DAG 自动化流程的模板与运行监控。"
|
||||||
|
features={['流程模板', '运行记录', '失败重试']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="自动化"
|
||||||
|
icon={TerminalSquare}
|
||||||
|
description="定时任务、触发器与事件驱动自动化。"
|
||||||
|
features={['任务编排', '触发器', '执行日志']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppsAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="应用管理"
|
||||||
|
icon={AppWindow}
|
||||||
|
description="平台内置应用的上架、配置与下线。"
|
||||||
|
features={['应用列表', '配置项', '灰度发布']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeExecPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="代码执行"
|
||||||
|
icon={Code2}
|
||||||
|
description="沙箱代码执行环境的安全与配额管控。"
|
||||||
|
features={['沙箱配置', '资源限额', '审计日志']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DistributionsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="分发渠道"
|
||||||
|
icon={Share2}
|
||||||
|
description="管理多渠道分发与推广链接。"
|
||||||
|
features={['渠道列表', '推广码', '转化统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="广告管理"
|
||||||
|
icon={Megaphone}
|
||||||
|
description="运营广告位与投放策略。"
|
||||||
|
features={['广告位', '创意管理', '曝光统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketAdminPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="应用市场"
|
||||||
|
icon={ShoppingBag}
|
||||||
|
description="技能/应用市场的审核、分类与运营。"
|
||||||
|
features={['上架审核', '分类运营', '交易对账']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="支付配置"
|
||||||
|
icon={CreditCard}
|
||||||
|
description="支付网关、回调与对账配置。"
|
||||||
|
features={['网关配置', '回调校验', '对账文件']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="账单计费"
|
||||||
|
icon={Receipt}
|
||||||
|
description="用量计费、账单生成与发票管理。"
|
||||||
|
features={['账单列表', '用量明细', '发票管理']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembershipPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="会员体系"
|
||||||
|
icon={Crown}
|
||||||
|
description="会员等级、权益与续费策略。"
|
||||||
|
features={['等级配置', '权益包', '续费统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="增值服务"
|
||||||
|
icon={Boxes}
|
||||||
|
description="管理付费增值服务与套餐。"
|
||||||
|
features={['服务列表', '套餐配置', '售卖统计']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="日志审计"
|
||||||
|
icon={ScrollText}
|
||||||
|
description="操作日志与审计轨迹,支持 CSV 导出。"
|
||||||
|
features={['操作日志', '审计追踪', '导出']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeveloperPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="开发者"
|
||||||
|
icon={Code2}
|
||||||
|
description="开放 API、密钥与开发者生态管理。"
|
||||||
|
features={['API 密钥', 'SDK 下载', '调用配额']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<AdminComingSoon
|
||||||
|
title="数据分析"
|
||||||
|
icon={BarChart3}
|
||||||
|
description="全平台运营数据看板与多维分析。"
|
||||||
|
features={['留存分析', '漏斗分析', '自定义报表']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 as any)?.data ?? (rawApps as any)?.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-primary/10 flex items-center justify-center">
|
||||||
|
{app.type === 'standalone' ? (
|
||||||
|
<Globe size={20} className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<Monitor size={20} className="text-primary" />
|
||||||
|
)}
|
||||||
|
</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,366 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Coins,
|
||||||
|
TrendingDown,
|
||||||
|
Boxes,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useTokenOverview,
|
||||||
|
useTokenRankings,
|
||||||
|
useTokenBalance,
|
||||||
|
useTokenLogs,
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import type { TokenLogItem } from '@/api/client';
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRankingBar({ name, total, max }: { name: string; total: number; max: number }) {
|
||||||
|
const pct = max > 0 ? Math.round((total / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-foreground truncate pr-2">{name}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">{formatNumber(total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 15;
|
||||||
|
|
||||||
|
const { data: overview, isLoading: ovLoading } = useTokenOverview();
|
||||||
|
const { data: rankings } = useTokenRankings();
|
||||||
|
const { data: balance } = useTokenBalance();
|
||||||
|
const { data: logs, isLoading: logsLoading, isFetching: logsFetching } = useTokenLogs({
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxModelTokens = useMemo(
|
||||||
|
() => Math.max(0, ...(rankings?.model_rankings ?? []).map((m) => m.total_tokens)),
|
||||||
|
[rankings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = logs?.total ?? 0;
|
||||||
|
const items: TokenLogItem[] = logs?.items ?? [];
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Token 驾驶舱</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
实时掌握 Token 消耗、余额与调用明细
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{overview?.server_time && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Clock size={12} /> 北京时间 {formatTime(overview.server_time)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Coins}
|
||||||
|
label="当前余额"
|
||||||
|
value={ovLoading ? '—' : `${formatNumber(overview?.balance ?? 0)}`}
|
||||||
|
hint="tokens 可用额度"
|
||||||
|
accent="bg-primary/10 text-primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingDown}
|
||||||
|
label="累计消耗"
|
||||||
|
value={ovLoading ? '—' : formatNumber(overview?.total_used ?? 0)}
|
||||||
|
hint="历史总 token 用量"
|
||||||
|
accent="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Boxes}
|
||||||
|
label="今日活跃模型"
|
||||||
|
value={ovLoading ? '—' : `${overview?.today_apps ?? 0}`}
|
||||||
|
hint="当日有调用的模型数"
|
||||||
|
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="调用记录"
|
||||||
|
value={logsLoading ? '—' : formatNumber(total)}
|
||||||
|
hint="筛选范围内总条数"
|
||||||
|
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rankings + balance side panel */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>模型消耗排行</CardTitle>
|
||||||
|
<CardDescription>按累计 token 消耗排序的模型分布</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!rankings || rankings.model_rankings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
暂无消耗数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
rankings.model_rankings.slice(0, 6).map((m) => (
|
||||||
|
<ModelRankingBar
|
||||||
|
key={m.model_id}
|
||||||
|
name={m.model_name}
|
||||||
|
total={m.total_tokens}
|
||||||
|
max={maxModelTokens}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>余额概览</CardTitle>
|
||||||
|
<CardDescription>账户 token 余额状态</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">当前余额</p>
|
||||||
|
<p className="text-3xl font-semibold text-foreground tabular-nums mt-1">
|
||||||
|
{balance ? formatNumber(balance.balance) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">tokens</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">最近更新</span>
|
||||||
|
<span className="text-foreground">{formatTime(balance?.updated_at ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">累计消耗</span>
|
||||||
|
<span className="text-foreground tabular-nums">
|
||||||
|
{formatNumber(overview?.total_used ?? 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rankings && rankings.top_apps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Top 应用(按消耗)</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rankings.top_apps.map((app, i) => (
|
||||||
|
<div
|
||||||
|
key={app.conversation_id}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-foreground truncate pr-2">
|
||||||
|
#{i + 1} {app.conversation_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatNumber(app.total_tokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>调用明细</CardTitle>
|
||||||
|
<CardDescription>Token 消耗逐条记录,支持日期筛选与分页</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">开始日期</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartDate(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">结束日期</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEndDate(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="gap-1" onClick={handleReset}>
|
||||||
|
<Filter size={14} /> 重置
|
||||||
|
</Button>
|
||||||
|
{logsFetching && <Loader2 size={16} className="animate-spin text-primary" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">时间</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">模型</th>
|
||||||
|
<th className="text-right font-medium px-4 py-2.5">Prompt</th>
|
||||||
|
<th className="text-right font-medium px-4 py-2.5">Completion</th>
|
||||||
|
<th className="text-right font-medium px-4 py-2.5">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logsLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-10 text-muted-foreground">
|
||||||
|
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-10 text-muted-foreground">
|
||||||
|
暂无调用记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((log) => (
|
||||||
|
<tr
|
||||||
|
key={log.id}
|
||||||
|
className="border-t border-border hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatTime(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-foreground">{log.model_name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||||
|
{formatNumber(log.prompt_tokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||||
|
{formatNumber(log.completion_tokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums font-medium text-foreground">
|
||||||
|
{formatNumber(log.total_tokens)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
第 {page} / {totalPages} 页 · 共 {formatNumber(total)} 条
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={page <= 1 || logsFetching}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={page >= totalPages || logsFetching}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页 <ChevronRight size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
@@ -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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-amber-50 via-orange-50 to-amber-100/60 dark:from-[#1C1814] dark:via-[#221C15] dark:to-[#2A2118] 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,629 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
User as UserIcon,
|
||||||
|
Crown,
|
||||||
|
Coins,
|
||||||
|
History,
|
||||||
|
KeyRound,
|
||||||
|
ShoppingBag,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
CalendarDays,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useProfile,
|
||||||
|
useProfileBalance,
|
||||||
|
useProfileMembership,
|
||||||
|
useProfileLoginLogs,
|
||||||
|
useProfileApiKeys,
|
||||||
|
useProfilePurchasedApps,
|
||||||
|
useUpdateProfile,
|
||||||
|
useUpdateProfilePassword,
|
||||||
|
useCreateApiKey,
|
||||||
|
useUpdateApiKey,
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Icon size={15} className="text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
|
||||||
|
<span className="text-foreground truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改资料:内联表单,提交后 invalidate profile 刷新只读数据
|
||||||
|
function EditProfileSection({ profile }: { profile?: { username: string; email: string; phone: string } }) {
|
||||||
|
const updateProfile = useUpdateProfile();
|
||||||
|
const [username, setUsername] = useState(profile?.username ?? '');
|
||||||
|
const [email, setEmail] = useState(profile?.email ?? '');
|
||||||
|
const [phone, setPhone] = useState(profile?.phone ?? '');
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setUsername(profile?.username ?? '');
|
||||||
|
setEmail(profile?.email ?? '');
|
||||||
|
setPhone(profile?.phone ?? '');
|
||||||
|
setError(null);
|
||||||
|
setOk(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setOk(false);
|
||||||
|
updateProfile.mutate(
|
||||||
|
{ username, email, phone },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setOk(true);
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onError: (err) => setError(err instanceof Error ? err.message : '更新失败'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" className="gap-1" onClick={() => { reset(); setEditing(true); }}>
|
||||||
|
<Pencil size={13} /> 编辑资料
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pf-username" className="text-xs">用户名</Label>
|
||||||
|
<Input id="pf-username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={updateProfile.isPending} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pf-email" className="text-xs">邮箱</Label>
|
||||||
|
<Input id="pf-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={updateProfile.isPending} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pf-phone" className="text-xs">手机</Label>
|
||||||
|
<Input id="pf-phone" value={phone} onChange={(e) => setPhone(e.target.value)} disabled={updateProfile.isPending} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
{ok && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 size={13} /> 资料已更新
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" size="sm" className="gap-1" disabled={updateProfile.isPending}>
|
||||||
|
{updateProfile.isPending && <Loader2 size={13} className="animate-spin" />} 保存
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => { setEditing(false); reset(); }} disabled={updateProfile.isPending}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改密:独立卡片,含原密码/新密码校验
|
||||||
|
function ChangePasswordSection() {
|
||||||
|
const changePwd = useUpdateProfilePassword();
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setOk(false);
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError('新密码至少 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePwd.mutate(
|
||||||
|
{ old_password: oldPassword, new_password: newPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setOk(true);
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
},
|
||||||
|
onError: (err) => setError(err instanceof Error ? err.message : '修改失败'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound size={16} className="text-muted-foreground" /> 修改密码
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>定期更新密码以保障账户安全</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3 max-w-sm">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pf-old" className="text-xs">原密码</Label>
|
||||||
|
<Input id="pf-old" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} disabled={changePwd.isPending} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pf-new" className="text-xs">新密码(至少 6 位)</Label>
|
||||||
|
<Input id="pf-new" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={changePwd.isPending} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
{ok && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 size={13} /> 密码修改成功
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" size="sm" className="gap-1" disabled={changePwd.isPending || !oldPassword || !newPassword}>
|
||||||
|
{changePwd.isPending && <Loader2 size={13} className="animate-spin" />} 确认修改
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const [logPage, setLogPage] = useState(1);
|
||||||
|
const logPageSize = 8;
|
||||||
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
|
|
||||||
|
const { data: profile, isLoading: pLoading } = useProfile();
|
||||||
|
const { data: balance, isLoading: bLoading } = useProfileBalance();
|
||||||
|
const { data: membership } = useProfileMembership();
|
||||||
|
const { data: loginLogs, isLoading: logsLoading, isFetching: logsFetching } = useProfileLoginLogs(
|
||||||
|
logPage,
|
||||||
|
logPageSize
|
||||||
|
);
|
||||||
|
const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys();
|
||||||
|
const { data: purchasedApps, isLoading: appsLoading } = useProfilePurchasedApps();
|
||||||
|
const createApiKey = useCreateApiKey();
|
||||||
|
const updateApiKey = useUpdateApiKey();
|
||||||
|
const [keyError, setKeyError] = useState<string | null>(null);
|
||||||
|
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const logTotal = loginLogs?.total ?? 0;
|
||||||
|
const logItems = loginLogs?.items ?? [];
|
||||||
|
const logTotalPages = Math.max(1, Math.ceil(logTotal / logPageSize));
|
||||||
|
|
||||||
|
const isExpired = membership?.is_expired ?? false;
|
||||||
|
const hasMembership = membership?.has_membership ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<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">
|
||||||
|
管理账户资料、会员状态、余额与 API 密钥
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{profile?.created_at && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<CalendarDays size={12} /> 注册于 {formatTime(profile.created_at).split(' ')[0]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile card + membership */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Profile identity */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>账户资料</CardTitle>
|
||||||
|
<CardDescription>当前登录账户的基础信息</CardDescription>
|
||||||
|
</div>
|
||||||
|
<EditProfileSection profile={profile} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xl font-semibold shrink-0">
|
||||||
|
{profile?.username?.[0]?.toUpperCase() ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-medium text-foreground truncate">
|
||||||
|
{pLoading ? '加载中…' : profile?.username ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{profile?.role ?? 'user'} · {profile?.status ?? 'active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<InfoRow icon={Mail} label="邮箱" value={profile?.email || '未设置'} />
|
||||||
|
<InfoRow icon={Phone} label="手机" value={profile?.phone || '未设置'} />
|
||||||
|
<InfoRow
|
||||||
|
icon={UserIcon}
|
||||||
|
label="用户ID"
|
||||||
|
value={profile?.id ? profile.id.slice(0, 12) : '—'}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={CalendarDays}
|
||||||
|
label="更新"
|
||||||
|
value={formatTime(profile?.updated_at ?? null).split(' ')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Membership */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Crown size={16} className="text-amber-500" /> 会员状态
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>当前订阅与有效期</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!hasMembership ? (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<Crown size={28} className="text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">尚未开通会员</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">套餐</span>
|
||||||
|
<Badge
|
||||||
|
variant={isExpired ? 'destructive' : 'default'}
|
||||||
|
className={isExpired ? '' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}
|
||||||
|
>
|
||||||
|
{isExpired ? '已过期' : '生效中'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">计划 ID</span>
|
||||||
|
<span className="text-foreground">{membership?.plan_id ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">生效</span>
|
||||||
|
<span className="text-foreground">{formatTime(membership?.start_at ?? null).split(' ')[0]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">到期</span>
|
||||||
|
<span className="text-foreground">{formatTime(membership?.expire_at ?? null).split(' ')[0]}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance stat row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Coins}
|
||||||
|
label="Token 余额"
|
||||||
|
value={bLoading ? '—' : formatNumber(balance ?? 0)}
|
||||||
|
hint="当前可用 tokens"
|
||||||
|
accent="bg-primary/10 text-primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={KeyRound}
|
||||||
|
label="API 密钥"
|
||||||
|
value={keysLoading ? '—' : `${apiKeys?.length ?? 0}`}
|
||||||
|
hint="已创建的密钥数"
|
||||||
|
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={ShoppingBag}
|
||||||
|
label="已购应用"
|
||||||
|
value={appsLoading ? '—' : `${purchasedApps?.length ?? 0}`}
|
||||||
|
hint="已下单的应用服务"
|
||||||
|
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change password */}
|
||||||
|
<ChangePasswordSection />
|
||||||
|
|
||||||
|
{/* Login logs */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History size={16} className="text-muted-foreground" /> 登录记录
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>最近账户登录 IP 与设备信息</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">时间</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">IP</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logsLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center py-10 text-muted-foreground">
|
||||||
|
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center py-10 text-muted-foreground">
|
||||||
|
暂无登录记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logItems.map((log) => (
|
||||||
|
<tr key={log.id} className="border-t border-border hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatTime(log.login_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-foreground tabular-nums">{log.ip}</td>
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground truncate max-w-[280px]">
|
||||||
|
{log.user_agent}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
第 {logPage} / {logTotalPages} 页 · 共 {formatNumber(logTotal)} 条
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={logPage <= 1 || logsFetching}
|
||||||
|
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={logPage >= logTotalPages || logsFetching}
|
||||||
|
onClick={() => setLogPage((p) => Math.min(logTotalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页 <ChevronRight size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Two-column: API keys + purchased apps */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* API Keys */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound size={16} className="text-muted-foreground" /> API 密钥
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>用于第三方集成的访问凭证</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* 新建密钥 */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor="pf-newkey" className="text-xs">新建密钥名称</Label>
|
||||||
|
<Input
|
||||||
|
id="pf-newkey"
|
||||||
|
placeholder="例如:我的应用"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => { setNewKeyName(e.target.value); setKeyError(null); }}
|
||||||
|
disabled={createApiKey.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={createApiKey.isPending || !newKeyName.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
createApiKey.mutate(newKeyName.trim(), {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setCreatedKey(res.api_key);
|
||||||
|
setNewKeyName('');
|
||||||
|
},
|
||||||
|
onError: (err) => setKeyError(err instanceof Error ? err.message : '创建失败'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createApiKey.isPending && <Loader2 size={13} className="animate-spin" />}
|
||||||
|
<Plus size={13} /> 创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keyError && <p className="text-xs text-destructive">{keyError}</p>}
|
||||||
|
|
||||||
|
{createdKey && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/30 p-3 space-y-1">
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1">
|
||||||
|
<CheckCircle2 size={13} /> 密钥已创建,请立即复制保存(仅显示一次)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono text-foreground break-all select-all">{createdKey}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keysLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||||
|
</div>
|
||||||
|
) : !apiKeys || apiKeys.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div>
|
||||||
|
) : (
|
||||||
|
apiKeys.map((k) => {
|
||||||
|
const active = k.status === 'active';
|
||||||
|
const revoked = k.status === 'revoked';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={k.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-foreground font-medium truncate">{k.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums font-mono truncate">
|
||||||
|
{k.key_preview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Badge variant={active ? 'default' : 'secondary'}>
|
||||||
|
{active ? '启用' : revoked ? '已撤销' : k.status}
|
||||||
|
</Badge>
|
||||||
|
{!revoked && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title={active ? '禁用' : '启用'}
|
||||||
|
disabled={updateApiKey.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateApiKey.mutate({
|
||||||
|
keyId: k.id,
|
||||||
|
status: active ? 'disabled' : 'enabled',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? <Trash2 size={13} /> : <CheckCircle2 size={13} />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!revoked && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
title="撤销"
|
||||||
|
disabled={updateApiKey.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateApiKey.mutate({ keyId: k.id, status: 'revoked' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Purchased apps */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShoppingBag size={16} className="text-muted-foreground" /> 已购应用
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>已下单的应用服务列表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{appsLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||||
|
</div>
|
||||||
|
) : !purchasedApps || purchasedApps.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">暂无已购应用</div>
|
||||||
|
) : (
|
||||||
|
purchasedApps.map((app) => (
|
||||||
|
<div
|
||||||
|
key={app.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||||
|
>
|
||||||
|
{app.app_icon ? (
|
||||||
|
<img
|
||||||
|
src={app.app_icon}
|
||||||
|
alt=""
|
||||||
|
className="w-9 h-9 rounded-md object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-md bg-muted flex items-center justify-center text-muted-foreground shrink-0">
|
||||||
|
<ShoppingBag size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-foreground font-medium truncate">{app.app_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{app.app_type} · {formatTime(app.purchased_at).split(' ')[0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-foreground tabular-nums shrink-0">
|
||||||
|
¥{formatNumber(app.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
@@ -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: #FFFBF5;
|
||||||
|
--foreground: #2A2018;
|
||||||
|
--card: #FFFFFF;
|
||||||
|
--card-foreground: #2A2018;
|
||||||
|
--primary: #D97706;
|
||||||
|
--primary-foreground: #FFFFFF;
|
||||||
|
--secondary: #F7F1E8;
|
||||||
|
--secondary-foreground: #2A2018;
|
||||||
|
--muted: #F7F1E8;
|
||||||
|
--muted-foreground: #9A8B76;
|
||||||
|
--accent: #D97706;
|
||||||
|
--accent-foreground: #FFFFFF;
|
||||||
|
--destructive: #D9383A;
|
||||||
|
--destructive-foreground: #FFFFFF;
|
||||||
|
--border: #ECE3D6;
|
||||||
|
--input: #ECE3D6;
|
||||||
|
--ring: #D97706;
|
||||||
|
|
||||||
|
/* --- 旧版变量 (兼容旧页面) --- */
|
||||||
|
--color-bg-primary: #FFFBF5;
|
||||||
|
--color-bg-secondary: #FFFFFF;
|
||||||
|
--color-bg-tertiary: #F7F1E8;
|
||||||
|
--color-bg-hover: #F2E9DB;
|
||||||
|
--color-bg-active: #F0E4D0;
|
||||||
|
|
||||||
|
--color-text-primary: #2A2018;
|
||||||
|
--color-text-secondary: #6B5D49;
|
||||||
|
--color-text-tertiary: #9A8B76;
|
||||||
|
--color-text-disabled: #C4B8A4;
|
||||||
|
--color-text-inverse: #FFFFFF;
|
||||||
|
|
||||||
|
--color-accent: #D97706;
|
||||||
|
--color-accent-hover: #B45309;
|
||||||
|
--color-accent-light: #FEF3C7;
|
||||||
|
--color-accent-subtle: #FFFBEB;
|
||||||
|
|
||||||
|
--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: #FBF7F0;
|
||||||
|
--color-sidebar-hover: #F4ECDD;
|
||||||
|
--color-sidebar-active: #F0E4D0;
|
||||||
|
|
||||||
|
/* --- 阴影 --- */
|
||||||
|
--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: #1C1814;
|
||||||
|
--foreground: #F5EFE6;
|
||||||
|
--card: #272019;
|
||||||
|
--card-foreground: #F5EFE6;
|
||||||
|
--primary: #F59E0B;
|
||||||
|
--primary-foreground: #1C1814;
|
||||||
|
--secondary: #2E281F;
|
||||||
|
--secondary-foreground: #F5EFE6;
|
||||||
|
--muted: #2E281F;
|
||||||
|
--muted-foreground: #A9967E;
|
||||||
|
--accent: #F59E0B;
|
||||||
|
--accent-foreground: #1C1814;
|
||||||
|
--destructive: #f87171;
|
||||||
|
--destructive-foreground: #1C1814;
|
||||||
|
--border: #3D352A;
|
||||||
|
--input: #3D352A;
|
||||||
|
--ring: #F59E0B;
|
||||||
|
|
||||||
|
/* 旧版变量暗色 */
|
||||||
|
--color-bg-primary: #1C1814;
|
||||||
|
--color-bg-secondary: #272019;
|
||||||
|
--color-bg-tertiary: #2E281F;
|
||||||
|
--color-bg-hover: #3D352A;
|
||||||
|
--color-bg-active: #4D4334;
|
||||||
|
--color-text-primary: #F5EFE6;
|
||||||
|
--color-text-secondary: #D6C7B3;
|
||||||
|
--color-text-tertiary: #A9967E;
|
||||||
|
--color-text-disabled: #5C5240;
|
||||||
|
--color-accent: #F59E0B;
|
||||||
|
--color-accent-hover: #FBBF24;
|
||||||
|
--color-accent-light: rgba(245,158,11,0.15);
|
||||||
|
--color-border: #3D352A;
|
||||||
|
--color-border-light: #272019;
|
||||||
|
--color-border-hover: #4D4334;
|
||||||
|
--color-sidebar-bg: #272019;
|
||||||
|
--color-sidebar-hover: #3D352A;
|
||||||
|
--color-sidebar-active: #4D4334;
|
||||||
|
|
||||||
|
--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(217,119,6,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