From 1ba81ba0d1c2b77e25a4e106aa5a1b762dbb38d9 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Sun, 23 Aug 2026 13:50:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend-v2):=20/market=20=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=B8=82=E5=9C=BA=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=A1=B5=20-=20=E9=87=8D=E5=86=99=20MarketApp=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B+marketApi=20=E5=AF=B9=E9=BD=90=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=A5=91=E7=BA=A6(#993/#994)=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20MarketPage=20=E6=9B=BF=E6=8D=A2=20ComingSo?= =?UTF-8?q?on(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-v2/frontend-v2/src/App.tsx | 154 ++++++ frontend-v2/frontend-v2/src/api/client.ts | 515 ++++++++++++++++++ frontend-v2/frontend-v2/src/hooks/useApi.ts | 364 +++++++++++++ .../src/pages/front/AppServices/index.tsx | 110 ++++ .../src/pages/front/MarketPage/index.tsx | 345 ++++++++++++ 5 files changed, 1488 insertions(+) create mode 100644 frontend-v2/frontend-v2/src/App.tsx create mode 100644 frontend-v2/frontend-v2/src/api/client.ts create mode 100644 frontend-v2/frontend-v2/src/hooks/useApi.ts create mode 100644 frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx create mode 100644 frontend-v2/frontend-v2/src/pages/front/MarketPage/index.tsx diff --git a/frontend-v2/frontend-v2/src/App.tsx b/frontend-v2/frontend-v2/src/App.tsx new file mode 100644 index 0000000..d65e4ac --- /dev/null +++ b/frontend-v2/frontend-v2/src/App.tsx @@ -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 ( + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + {/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */} + } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + } /> + } /> + + } + /> + + } + /> + + } /> + + + + ); +} + +export default App; diff --git a/frontend-v2/frontend-v2/src/api/client.ts b/frontend-v2/frontend-v2/src/api/client.ts new file mode 100644 index 0000000..503aba2 --- /dev/null +++ b/frontend-v2/frontend-v2/src/api/client.ts @@ -0,0 +1,515 @@ +const BASE_URL = import.meta.env.VITE_API_URL || '/app/api'; + +async function request(path: string, options: RequestInit = {}): Promise { + const token = localStorage.getItem('auth_token'); + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers as Record || {}), + }; + + 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()` returns the raw envelope; `data()` unwraps `.data`. +async function data(path: string, options: RequestInit = {}): Promise { + 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('/chat/conversations'), + + createConversation: (data: { title?: string; model?: string }) => + request('/chat/conversations', { + method: 'POST', + body: JSON.stringify(data), + }), + + deleteConversation: (id: string) => + request(`/chat/conversations/${id}`, { method: 'DELETE' }), + + getMessages: (convId: string) => + request(`/chat/conversations/${convId}/messages`), + + sendMessage: (convId: string, data: { content: string; model?: string }) => + request(`/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('/knowledge/bases'), + + createBase: (data: { name: string; description?: string }) => + request('/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(`/apps${q ? `?${q}` : ''}`); + }, + + getApp: (appId: string) => + data(`/apps/${appId}`), + + getMyApps: () => + request('/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(`/reviews/${appId}${q ? `?${q}` : ''}`); + }, + + getAds: () => + data('/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('/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('/profile/'), + + update: (payload: Partial>) => + data('/profile/', { method: 'PUT', body: JSON.stringify(payload) }), + + getBalance: () => data('/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(`/profile/login-logs?page=${page}&page_size=${page_size}`), + + getApiKeys: () => data('/profile/api-keys'), + + getPurchasedApps: () => data('/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('/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('/token/overview'), + + getRankings: () => data('/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(`/token/logs${q ? `?${q}` : ''}`); + }, + + getBalance: () => data('/token/balance'), +}; diff --git a/frontend-v2/frontend-v2/src/hooks/useApi.ts b/frontend-v2/frontend-v2/src/hooks/useApi.ts new file mode 100644 index 0000000..dd87f63 --- /dev/null +++ b/frontend-v2/frontend-v2/src/hooks/useApi.ts @@ -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(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(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(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[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, // 翻页时保留旧数据,避免闪烁 + }); +} diff --git a/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx b/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx new file mode 100644 index 0000000..57efde5 --- /dev/null +++ b/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx @@ -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 ( +
+
+
+

应用中心

+

管理第三方 AI 应用

+
+ +
+ + + {isLoading ? ( +
+ +
+ ) : apps.length === 0 ? ( +
+ +

暂无应用

+

去应用市场浏览并安装应用

+
+ ) : ( + apps.map((app: MarketApp) => ( + + +
+
+ {app.type === 'standalone' ? ( + + ) : ( + + )} +
+
+ + {app.installed ? '已安装' : '未安装'} + + + {app.status === 'published' ? '已发布' : app.status} + +
+
+ +

{app.name}

+

{app.description}

+ + + +
+ + +
+
+
+ )) + )} + + {/* Create New */} + + +
+ +
+

创建新应用

+
+
+
+
+ ); +} diff --git a/frontend-v2/frontend-v2/src/pages/front/MarketPage/index.tsx b/frontend-v2/frontend-v2/src/pages/front/MarketPage/index.tsx new file mode 100644 index 0000000..961318d --- /dev/null +++ b/frontend-v2/frontend-v2/src/pages/front/MarketPage/index.tsx @@ -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 = { + standalone: '独立应用', + embedded: '嵌入式', + skill: '技能', + agent: '智能体', + workflow: '工作流', +}; + +const TYPE_ICONS: Record = { + standalone: Globe, + embedded: Puzzle, + skill: Sparkles, + agent: Bot, + workflow: Workflow, +}; + +const SOURCE_LABELS: Record = { + 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(undefined); + const [sort, setSort] = useState('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 ( +
+ {/* 头部 */} +
+
+

+ + 技能市场 +

+

+ 浏览、安装官方与社区技能,一键接入平台能力 +

+
+
+ + {/* 广告位 */} + {ads && ads.length > 0 && ( + +
+
+ +
+
+

{ads[0].title}

+

点击查看详情 →

+
+
+
+ )} + + {/* 搜索 + 排序 */} +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + placeholder="搜索技能、智能体、工作流…" + className="pl-9" + /> +
+
+ {(['popular', 'latest', 'rating'] as MarketSort[]).map((s) => ( + + ))} +
+
+ + {/* 类型筛选 */} +
+ {typeFilters.map((tf) => ( + + ))} +
+ + + + {/* 列表 */} + {isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

未找到匹配的技能

+

试试调整搜索词或筛选条件

+
+ ) : ( + + {items.map((app: MarketApp) => { + const TypeIcon = TYPE_ICONS[app.type] ?? Sparkles; + const price = formatPrice(app); + return ( + + +
+
+ {app.icon ? ( + {app.name} + ) : ( + + )} +
+
+ {app.verified && ( + + + 已认证 + + )} + {app.installed ? ( + + + 已安装 + + ) : ( + {SOURCE_LABELS[app.source]} + )} +
+
+ +
+

{app.name}

+ {app.is_member_free && ( + + )} +
+

+ 由 {app.publisher_name} 发布 +

+ +

+ {app.description} +

+ +
+ {app.rating_avg > 0 && ( + + + {app.rating_avg.toFixed(1)} + + )} + + + {formatCount(app.install_count)} + + + {TYPE_LABELS[app.type]} + +
+ + + +
+ + {price.label} + + {app.installed ? ( + + ) : ( + + )} +
+
+
+ ); + })} +
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ ); +}