Compare commits

...
12 Commits
Author SHA1 Message Date
agent 25f3edac0a feat(admin): 后台脚手架-布局/路由/守卫 + Dashboard 真实数据页 (#1013)
CI / gate (push) Has been cancelled
2026-08-24 11:32:18 +08:00
qihuaiflow 7b8c3ba2dd feat(frontend): Notifications + Evolution 真实数据页接用户端 API (#1008/#1009) 2026-08-24 08:47:36 +08:00
QihuAI Design 8f30a588fb feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包 2026-08-23 15:54:43 +08:00
QihuAI Design 1ba81ba0d1 feat(frontend-v2): /market 技能市场真实数据页 - 重写 MarketApp 类型+marketApi 对齐真实后端契约(#993/#994),新增 MarketPage 替换 ComingSoon(#995) 2026-08-23 13:50:11 +08:00
agent 66f696c1e6 feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991) 2026-08-23 13:21:16 +08:00
QihuAI Design 09440c2577 feat(frontend-v2): ProfilePage 接 profile API - client/useApi/App 对齐真实契约 (#987-#990) 2026-08-23 12:30:20 +08:00
QihuAI Design 8a9c2e4868 feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) 接 profile API 6 区块 2026-08-23 12:28:41 +08:00
QihuAI Design 39fcdbf6da feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) - 接 profile API 6 区块 2026-08-23 12:26:56 +08:00
QihuAI Design 910394423d feat(frontend-v2): #986 Token 驾驶舱真实数据页 (token/overview|rankings|logs|balance) 2026-08-23 12:02:37 +08:00
QihuAI Design 4a2920e637 #985 阶段3: 9个占位路由升级为 amber 金暖白 ComingSoon 页 (统一品牌感+功能预告) 2026-08-23 11:36:02 +08:00
QihuAI Design 6f192a89cf #983/#984 amber 金暖白设计语言:globals.css 主色令牌 indigo->amber + 3处硬编码色修正 2026-08-23 11:21:22 +08:00
QihuAI Design 8f57956904 #978 safe-align: 固化 frontend-v2 空白页修复真相源
- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git)
- vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice)
- useApi.ts: asArray select 防御非裸数组返回值
- LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice)
- 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md
- 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
2026-08-23 09:36:35 +08:00
53 changed files with 13238 additions and 0 deletions

No files matched your search

+126
View File
@@ -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;
+767
View File
@@ -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}`),
};
+496
View File
@@ -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>
);
}
+13
View File
@@ -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>
+4577
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -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"
}
}
+4
View File
@@ -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

+208
View File
@@ -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;
+816
View File
@@ -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>
);
}
+25
View File
@@ -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 };
+29
View File
@@ -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 };
+52
View File
@@ -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 };
+46
View File
@@ -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 };
+66
View File
@@ -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 };
+21
View File
@@ -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 };
+18
View File
@@ -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 };
+43
View File
@@ -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 };
+21
View File
@@ -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 };
+312
View File
@@ -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, // 翻页时保留旧数据,避免闪烁
});
}
+11
View File
@@ -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,
},
},
});
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+24
View File
@@ -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>
);
+18
View File
@@ -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>
);
+10
View File
@@ -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>
);
}
+214
View File
@@ -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;
+395
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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"]
}
+86
View File
@@ -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,
},
},
},
});