feat(frontend-v2): /market 技能市场真实数据页 - 重写 MarketApp 类型+marketApi 对齐真实后端契约(#993/#994),新增 MarketPage 替换 ComingSoon(#995)

This commit is contained in:
QihuAI Design committed 2026-08-23 13:50:11 +08:00
1 parent 66f696c1e6
commit 1ba81ba0d1
5 files changed
+1488

No files matched your search

+154
View File
@@ -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;
+515
View File
@@ -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'),
};
+364
View File
@@ -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>
);
}