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