feat(frontend-v2): /market 技能市场真实数据页 - 重写 MarketApp 类型+marketApi 对齐真实后端契约(#993/#994),新增 MarketPage 替换 ComingSoon(#995)
This commit is contained in:
1 parent
66f696c1e6
commit
1ba81ba0d1
5 files changed
+1488
No files matched your search
@@ -0,0 +1,154 @@
|
|||||||
|
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 { ComingSoon } from '@/components/common/ComingSoon';
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Puzzle,
|
||||||
|
Workflow,
|
||||||
|
Shield,
|
||||||
|
Radio,
|
||||||
|
Bell,
|
||||||
|
Sparkles,
|
||||||
|
} 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={
|
||||||
|
<ComingSoon
|
||||||
|
title="消息通知"
|
||||||
|
icon={Bell}
|
||||||
|
description="集中查看系统通知与任务动态。"
|
||||||
|
features={[
|
||||||
|
'任务完成 / 失败提醒',
|
||||||
|
'配额与账单告警',
|
||||||
|
'按渠道订阅推送',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="evolution"
|
||||||
|
element={
|
||||||
|
<ComingSoon
|
||||||
|
title="自进化"
|
||||||
|
icon={Sparkles}
|
||||||
|
description="让平台从使用中持续学习、自我优化能力。"
|
||||||
|
features={[
|
||||||
|
'对话与工具调用沉淀',
|
||||||
|
'技能自动迭代建议',
|
||||||
|
'知识库增量同步',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (data: { username: string; password: string }) =>
|
||||||
|
request<{ access_token: string; token_type: string }>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
register: (data: { username: string; email: string; password: string; code: string }) =>
|
||||||
|
request<{ access_token: string }>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
me: () => request<{ id: string; username: string; email: string; avatar_url: string }>('/auth/me'),
|
||||||
|
|
||||||
|
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||||
|
|
||||||
|
sendCode: (data: { email: string }) =>
|
||||||
|
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatApi = {
|
||||||
|
listConversations: () => request<Conversation[]>('/chat/conversations'),
|
||||||
|
|
||||||
|
createConversation: (data: { title?: string; model?: string }) =>
|
||||||
|
request<Conversation>('/chat/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteConversation: (id: string) =>
|
||||||
|
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getMessages: (convId: string) =>
|
||||||
|
request<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||||
|
|
||||||
|
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||||
|
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
if (!reader) return;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.content) yield data.content;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KnowledgeBase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
document_count: number;
|
||||||
|
status: 'active' | 'building' | 'error';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeApi = {
|
||||||
|
listBases: () => request<KnowledgeBase[]>('/knowledge/bases'),
|
||||||
|
|
||||||
|
createBase: (data: { name: string; description?: string }) =>
|
||||||
|
request<KnowledgeBase>('/knowledge/bases', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteBase: (id: string) =>
|
||||||
|
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
search: (baseId: string, query: string) =>
|
||||||
|
request<{ results: { content: string; score: number; source: string }[] }>(
|
||||||
|
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
|
||||||
|
),
|
||||||
|
|
||||||
|
getDocuments: (baseId: string) =>
|
||||||
|
request<{ id: string; name: string; status: string; size: number }[]>(
|
||||||
|
`/knowledge/bases/${baseId}/documents`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||||
|
// 后端契约: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';
|
||||||
|
|
||||||
|
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: () =>
|
||||||
|
request<MarketApp[]>('/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 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImBotStatus {
|
||||||
|
status: 'connected' | 'disconnected' | 'expired';
|
||||||
|
ilink_bot_id: string;
|
||||||
|
connected_at: string;
|
||||||
|
last_active: string;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imBotApi = {
|
||||||
|
getStatus: () => request<ImBotStatus>('/im-bot/status'),
|
||||||
|
|
||||||
|
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||||
|
|
||||||
|
checkQRStatus: (ticket: string) =>
|
||||||
|
request<{ status: string }>('/im-bot/qrcode/status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ticket }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
|
||||||
|
|
||||||
|
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface 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'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
authApi,
|
||||||
|
chatApi,
|
||||||
|
knowledgeApi,
|
||||||
|
marketApi,
|
||||||
|
imBotApi,
|
||||||
|
profileApi,
|
||||||
|
tokenApi,
|
||||||
|
type TokenOverview,
|
||||||
|
type MarketApp,
|
||||||
|
type MarketAd,
|
||||||
|
type MarketSort,
|
||||||
|
type MarketAppType,
|
||||||
|
type MarketAppSource,
|
||||||
|
} 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 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: unknown) => asArray<MarketApp>(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, // 翻页时保留旧数据,避免闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.installed ? 'success' : 'secondary'}>
|
||||||
|
{app.installed ? '已安装' : '未安装'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{app.status === 'published' ? '已发布' : app.status}
|
||||||
|
</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.installed ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{app.installed ? (
|
||||||
|
<><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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user