fix: KB upload (assets.content column), KG conversion, achievement SQL index, card font color, markdown renderer
This commit is contained in:
1 parent
985e0548b0
commit
ce0a7b4243
42 files changed
+29056
No files matched your search
@@ -0,0 +1,990 @@
|
||||
/**
|
||||
* AIFlow API 客户端
|
||||
* 统一管理所有前后端API请求
|
||||
*/
|
||||
const API_BASE = '/app/api';
|
||||
|
||||
// 从localStorage获取token
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('aiflow_token');
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
export async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ code: number; data: T; message: string }> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// 登录接口的401需要精确提示(用户名不存在 / 密码错误)
|
||||
if (endpoint === '/auth/login') {
|
||||
const err = await res.json().catch(() => ({ detail: '登录失败' }));
|
||||
const error: any = new Error(err.detail || '登录失败');
|
||||
error.status = 401;
|
||||
error.detail = err.detail;
|
||||
throw error;
|
||||
}
|
||||
// 其他接口的401 = token过期/未登录,清除状态并跳转
|
||||
localStorage.removeItem('aiflow_token');
|
||||
localStorage.removeItem('aiflow_user');
|
||||
window.location.hash = '#/login';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '请求失败' }));
|
||||
const error: any = new Error(err.detail || err.message || '请求失败');
|
||||
error.status = res.status;
|
||||
error.response = { status: res.status, data: err };
|
||||
error.detail = err.detail;
|
||||
throw error;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// GET请求
|
||||
function get<T>(endpoint: string): Promise<{ code: number; data: T; message: string }> {
|
||||
return request<T>(endpoint);
|
||||
}
|
||||
|
||||
// POST请求
|
||||
function post<T>(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> {
|
||||
return request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
function put<T>(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> {
|
||||
return request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE请求
|
||||
function del<T>(endpoint: string): Promise<{ code: number; data: T; message: string }> {
|
||||
return request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 认证相关API
|
||||
// ============================================================
|
||||
export const authAPI = {
|
||||
login: (username: string, password: string) =>
|
||||
post<any>('/auth/login', { username, password }),
|
||||
register: (data: { username: string; password: string; email?: string; phone?: string }) =>
|
||||
post<any>('/auth/register', data),
|
||||
getMe: () => get<any>('/auth/me'),
|
||||
logout: () => post<any>('/auth/logout'),
|
||||
getSettings: () => get<any>('/auth/settings'),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 聊天相关API
|
||||
// ============================================================
|
||||
export const chatAPI = {
|
||||
getConversations: (status?: string) =>
|
||||
get<any[]>(`/chat/conversations${status ? `?status=${status}` : ''}`),
|
||||
createConversation: (data: { title: string; model_id?: string }) =>
|
||||
post<any>('/chat/conversations', data),
|
||||
updateConversation: (id: string, data: any) =>
|
||||
put<any>(`/chat/conversations/${id}`, data),
|
||||
deleteConversation: (id: string) =>
|
||||
del<any>(`/chat/conversations/${id}`),
|
||||
getMessages: (convId: string) =>
|
||||
get<any[]>(`/chat/conversations/${convId}/messages`),
|
||||
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_base_ids?: string[]; use_tools?: boolean }) =>
|
||||
post<any>(`/chat/conversations/${convId}/messages`, data),
|
||||
clearContext: (convId: string) =>
|
||||
post<any>(`/chat/conversations/${convId}/clear`),
|
||||
convertToKnowledgeGraph: (convId: string) =>
|
||||
post<any>(`/chat/conversations/${convId}/to-knowledge-graph/`),
|
||||
getModels: () => get<any[]>('/chat/models'),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 频道相关API
|
||||
// ============================================================
|
||||
export const channelAPI = {
|
||||
getChannels: () => get<any[]>('/channels/'),
|
||||
createChannel: (data: any) => post<any>('/channels/', data),
|
||||
getChannel: (id: string) => get<any>(`/channels/${id}`),
|
||||
updateChannel: (id: string, data: any) => put<any>(`/channels/${id}`, data),
|
||||
deleteChannel: (id: string) => del<any>(`/channels/${id}`),
|
||||
connectChannel: (id: string) => post<any>(`/channels/${id}/connect`),
|
||||
disconnectChannel: (id: string) => post<any>(`/channels/${id}/disconnect`),
|
||||
getPlatformConfigs: () => get<any>('/channels/platforms/config'),
|
||||
webhookMessage: (id: string, data: { from_user: string; content: string; message_type?: string }) =>
|
||||
post<any>(`/channels/${id}/webhook`, data),
|
||||
getMembers: (id: string) => get<any[]>(`/channels/${id}/members`),
|
||||
addMember: (id: string, userId: string) => post<any>(`/channels/${id}/members`, { user_id: userId }),
|
||||
removeMember: (id: string, userId: string) => del<any>(`/channels/${id}/members/${userId}`),
|
||||
getMessages: (id: string, params?: { limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return get<any[]>(`/channels/${id}/messages${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
sendMessage: (id: string, content: string) => post<any>(`/channels/${id}/messages`, { content }),
|
||||
authorize: (platform: string) => post<any>('/channels/authorize', { platform }),
|
||||
scanAuthorize: (platform: string, credentials?: Record<string, string>) => post<any>(`/channels/authorize/scan?platform=${platform}`, credentials ? { credentials } : {}),
|
||||
scanAuthorizeStatus: (platform: string) => post<any>(`/channels/authorize/scan/status?platform=${platform}`),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 知识库相关API
|
||||
// ============================================================
|
||||
export const knowledgeAPI = {
|
||||
getBases: () => get<any[]>('/knowledge/bases'),
|
||||
createBase: (data: any) => post<any>('/knowledge/bases', data),
|
||||
getBase: (id: string) => get<any>(`/knowledge/bases/${id}`),
|
||||
updateBase: (id: string, data: any) => put<any>(`/knowledge/bases/${id}`, data),
|
||||
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
|
||||
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
|
||||
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
|
||||
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents/`, data),
|
||||
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
|
||||
getDirectories: () => get<any[]>('/knowledge/directories'),
|
||||
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
|
||||
buildGraph: (baseId: string) => post<any>(`/knowledge/bases/${baseId}/build-graph`),
|
||||
getGraph: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/graph`),
|
||||
searchQA: (baseId: string, question: string) => post<any>('/knowledge/qa', { base_id: baseId, question }),
|
||||
searchDocuments: (baseId: string, keyword: string) => get<any[]>(`/knowledge/bases/${baseId}/search?keyword=${encodeURIComponent(keyword)}`),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 应用市场相关API
|
||||
// ============================================================
|
||||
export const marketAPI = {
|
||||
getApps: (params?: { type?: string; source?: string; search?: string; page?: number; page_size?: number }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.source) searchParams.set('source', params.source);
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.page_size) searchParams.set('page_size', String(params.page_size));
|
||||
const qs = searchParams.toString();
|
||||
return get<any>(`/market/apps${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getApp: (id: string) => get<any>(`/market/apps/${id}`),
|
||||
createApp: (data: any) => post<any>('/market/apps', data),
|
||||
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
|
||||
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
||||
uninstallApp: (id: string) => del<any>(`/market/apps/${id}/uninstall`),
|
||||
reviewApp: (id: string, data: { rating: number; content?: string }) =>
|
||||
post<any>(`/market/apps/${id}/review`, data),
|
||||
getMyApps: () => get<any[]>('/market/my-apps'),
|
||||
getReviews: (appId: string) => get<any[]>(`/market/reviews/${appId}`),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Token相关API
|
||||
// ============================================================
|
||||
export const tokenAPI = {
|
||||
getOverview: () => get<any>('/token/overview'),
|
||||
getRankings: () => get<any>('/token/rankings'),
|
||||
getLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
const qs = sp.toString();
|
||||
return get<any[]>(`/token/logs${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getBalance: () => get<any>('/token/balance'),
|
||||
recharge: (amount: number) => post<any>('/token/recharge', { amount }),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 付费相关API
|
||||
// ============================================================
|
||||
export const paymentAPI = {
|
||||
getPlans: () => get<any[]>('/payment/plans'),
|
||||
createOrder: (data: any) => post<any>('/payment/orders', data),
|
||||
getOrders: (status?: string) => get<any[]>(`/payment/orders${status ? `?status=${status}` : ''}`),
|
||||
getOrder: (id: string) => get<any>(`/payment/orders/${id}`),
|
||||
payOrder: (id: string, method?: string) => post<any>(`/payment/orders/${id}/pay`, { payment_method: method }),
|
||||
getCoupons: () => get<any[]>('/payment/coupons'),
|
||||
useCoupon: (code: string) => post<any>('/payment/coupons/use', { code }),
|
||||
getServices: () => get<any[]>('/payment/services'),
|
||||
createCustomOrder: (data: any) => post<any>('/payment/custom-services', data),
|
||||
createServiceRequest: (data: any) => post<any>('/payment/service-requests', data),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 个人中心相关API
|
||||
// ============================================================
|
||||
export const profileAPI = {
|
||||
getProfile: () => get<any>('/profile/'),
|
||||
updateProfile: (data: any) => put<any>('/profile/', data),
|
||||
updatePassword: (data: { old_password: string; new_password: string }) =>
|
||||
put<any>('/profile/password', data),
|
||||
getLoginLogs: () => get<any[]>('/profile/login-logs'),
|
||||
getApiKeys: () => get<any[]>('/profile/api-keys'),
|
||||
createApiKey: (name?: string) => post<any>('/profile/api-keys', { name }),
|
||||
updateApiKey: (id: string, status: string) => put<any>(`/profile/api-keys/${id}`, { status }),
|
||||
getMembership: () => get<any>('/profile/membership'),
|
||||
getPurchasedApps: () => get<any[]>('/profile/purchased-apps'),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 通知相关API
|
||||
// ============================================================
|
||||
export const notificationAPI = {
|
||||
getNotifications: (params?: { type?: string; page?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.type) sp.set('type', params.type);
|
||||
if (params?.page) sp.set('page', String(params.page));
|
||||
const qs = sp.toString();
|
||||
return get<any[]>(`/notifications/${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
markRead: (id: string) => put<any>(`/notifications/${id}/read`),
|
||||
markAllRead: () => put<any>('/notifications/read-all'),
|
||||
getUnreadCount: () => get<any>('/notifications/unread-count'),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 资产管理相关API
|
||||
// ============================================================
|
||||
export const assetsAPI = {
|
||||
getAssets: (category?: string) =>
|
||||
get<any[]>(`/assets/${category ? `?category=${category}` : ''}`),
|
||||
createAsset: (data: any) => post<any>('/assets/', data),
|
||||
getAsset: (id: string) => get<any>(`/assets/${id}`),
|
||||
updateAsset: (id: string, data: any) => put<any>(`/assets/${id}`, data),
|
||||
deleteAsset: (id: string) => del<any>(`/assets/${id}`),
|
||||
getCategories: () => get<any[]>('/assets/categories'),
|
||||
getNovelChapters: (novelId: string) => get<any[]>(`/assets/novels/${novelId}/chapters`),
|
||||
addChapter: (novelId: string, data: any) => post<any>(`/assets/novels/${novelId}/chapters`, data),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 后台管理API
|
||||
// ============================================================
|
||||
export const adminAPI = {
|
||||
// 仪表盘
|
||||
getStats: () => get<any>('/admin/dashboard/stats'),
|
||||
getRecentLogs: () => get<any[]>('/admin/dashboard/recent-logs'),
|
||||
getRecentAudit: () => get<any[]>('/admin/dashboard/recent-audit'),
|
||||
getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`),
|
||||
// 用户管理
|
||||
getUsers: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/users/${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getUser: (id: string) => get<any>(`/admin/users/${id}`),
|
||||
banUser: (id: string) => put<any>(`/admin/users/${id}/ban`),
|
||||
unbanUser: (id: string) => put<any>(`/admin/users/${id}/unban`),
|
||||
getRegisterSettings: () => get<any>('/admin/users/register-settings'),
|
||||
updateRegisterSettings: (data: any) => put<any>('/admin/users/register-settings', data),
|
||||
// 模型管理
|
||||
getProviders: () => get<any[]>('/admin/models/providers'),
|
||||
addProvider: (data: any) => post<any>('/admin/models/providers', data),
|
||||
updateProvider: (id: string, data: any) => put<any>(`/admin/models/providers/${id}`, data),
|
||||
deleteProvider: (id: string) => del<any>(`/admin/models/providers/${id}`),
|
||||
fetchModels: (id: string) => post<any>(`/admin/models/providers/${id}/fetch-models`),
|
||||
addModels: (id: string, modelIds: string[]) => post<any>(`/admin/models/providers/${id}/add-models`, { model_ids: modelIds }),
|
||||
getProtocols: () => get<any[]>('/admin/models/protocols'),
|
||||
getAdminModels: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/models/${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
addModel: (data: any) => post<any>('/admin/models/', data),
|
||||
updateModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
|
||||
deleteModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||
toggleModel: (id: string, isEnabled: boolean) => put<any>(`/admin/models/${id}/toggle?is_enabled=${isEnabled}`),
|
||||
setDefaultModel: (id: string) => put<any>(`/admin/models/${id}/set-default`),
|
||||
// Phase 2: Enhanced model management
|
||||
testModelConnection: (id: string) => post<any>(`/admin/models/${id}/test-connection`),
|
||||
testAllModels: () => post<any>('/admin/models/test-all'),
|
||||
fetchProviderModels: (id: string) => post<any>(`/admin/models/providers/${id}/fetch-models`),
|
||||
createAdminModel: (data: any) => post<any>('/admin/models/', data),
|
||||
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
|
||||
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||
getModelStats: () => get<any>('/admin/models/stats'),
|
||||
// 网站设置
|
||||
getSiteSettings: () => get<any>('/admin/settings/site'),
|
||||
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
|
||||
getEmailConfig: () => get<any>('/admin/settings/email'),
|
||||
updateEmailConfig: (data: any) => put<any>('/admin/settings/email', data),
|
||||
testEmail: (params?: { test_to?: string; test_subject?: string; test_body?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.test_to) qs.set('test_to', params.test_to);
|
||||
if (params?.test_subject) qs.set('test_subject', params.test_subject);
|
||||
if (params?.test_body) qs.set('test_body', params.test_body);
|
||||
const qstr = qs.toString();
|
||||
return post<any>(`/admin/settings/email/test${qstr ? `?${qstr}` : ''}`);
|
||||
},
|
||||
generateEmailContent: (topic: string) => post<any>(`/admin/settings/email/generate-content?topic=${encodeURIComponent(topic)}`),
|
||||
getSmsConfig: () => get<any>('/admin/settings/sms'),
|
||||
updateSmsConfig: (data: any) => put<any>('/admin/settings/sms', data),
|
||||
testSms: () => post<any>('/admin/settings/sms/test'),
|
||||
// 支付配置
|
||||
getPaymentConfigs: () => get<any[]>('/admin/payment/configs'),
|
||||
updatePaymentConfig: (id: string, data: any) => put<any>(`/admin/payment/configs/${id}`, data),
|
||||
getAdminOrders: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/payment/orders${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
refundOrder: (id: string) => put<any>(`/admin/payment/orders/${id}/refund`),
|
||||
// 增值服务
|
||||
getServices: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/services/${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
addService: (data: any) => post<any>('/admin/services/', data),
|
||||
updateService: (id: string, data: any) => put<any>(`/admin/services/${id}`, data),
|
||||
deleteService: (id: string) => del<any>(`/admin/services/${id}`),
|
||||
getCustomOrders: (status?: string) => get<any[]>(`/admin/services/custom-orders${status ? `?status=${status}` : ''}`),
|
||||
updateCustomOrder: (id: string, data: any) => put<any>(`/admin/services/custom-orders/${id}`, data),
|
||||
getCustomRequests: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/services/custom-requests${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
updateCustomRequest: (id: string, data: any) => put<any>(`/admin/services/custom-requests/${id}`, data),
|
||||
getAvailableAgents: () => get<any[]>('/admin/services/agents'),
|
||||
assignAgentToService: (id: string, agentId: string) => put<any>(`/admin/services/${id}/agent?agent_id=${agentId}`),
|
||||
// 会员管理(增强版)
|
||||
getPlans: () => get<any[]>('/admin/membership/plans'),
|
||||
addPlan: (params: string) => post<any>(`/admin/membership/plans${params}`),
|
||||
updatePlan: (id: string, params: string) => put<any>(`/admin/membership/plans/${id}${params}`),
|
||||
deletePlan: (id: string) => del<any>(`/admin/membership/plans/${id}`),
|
||||
getSubscriptions: (page?: number) => get<any>(`/admin/membership/subscriptions${page ? `?page=${page}` : ''}`),
|
||||
getAutomationRules: () => get<any>('/admin/membership/automation-rules'),
|
||||
getBenefitsComparison: () => get<any>('/admin/membership/benefits-comparison'),
|
||||
getMembershipConfigs: () => get<any[]>('/admin/membership/global-configs'),
|
||||
updateMembershipConfig: (key: string, params: string) => put<any>(`/admin/membership/global-configs/${key}${params}`),
|
||||
getUserMemberships: (page?: number, planId?: string, status?: string) => {
|
||||
const p = new URLSearchParams();
|
||||
if (page) p.append('page', String(page));
|
||||
if (planId) p.append('plan_id', planId);
|
||||
if (status) p.append('status', status);
|
||||
const qs = p.toString();
|
||||
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`),
|
||||
getPaymentLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getAuditLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
// 新增:日志统计
|
||||
getLogStats: () => get<any>('/admin/logs/stats'),
|
||||
// 新增:API调用统计
|
||||
getApiStats: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/logs/api-stats${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
// 新增:慢请求记录
|
||||
getSlowRequests: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/logs/slow-requests${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
// 新增:操作类型枚举
|
||||
getActionTypes: () => get<any>('/admin/logs/action-types'),
|
||||
// 新增:审计日志导出
|
||||
exportAuditLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/logs/export/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
exportOperationLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/logs/export/operations${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
exportTokenLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/logs/export/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getRiskRules: () => get<any[]>('/admin/logs/risk-rules'),
|
||||
addRiskRule: (data: any) => post<any>('/admin/logs/risk-rules', data),
|
||||
updateRiskRule: (id: string, data: any) => put<any>(`/admin/logs/risk-rules/${id}`, data),
|
||||
deleteRiskRule: (id: string) => del<any>(`/admin/logs/risk-rules/${id}`),
|
||||
// 技能管理 (Phase 2.2)
|
||||
getAdminSkills: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/skills/${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getAdminSkill: (id: string) => get<any>(`/admin/skills/${id}`),
|
||||
createAdminSkill: (data: any) => post<any>('/admin/skills/', data),
|
||||
updateAdminSkill: (id: string, data: any) => put<any>(`/admin/skills/${id}`, data),
|
||||
deleteAdminSkill: (id: string) => del<any>(`/admin/skills/${id}`),
|
||||
changeAdminSkillState: (id: string, data: any) => put<any>(`/admin/skills/${id}/state`, data),
|
||||
getAdminSkillVersions: (id: string) => get<any>(`/admin/skills/${id}/versions`),
|
||||
rollbackAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/rollback`, data),
|
||||
publishAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/publish`, data),
|
||||
unpublishAdminSkill: (id: string) => post<any>(`/admin/skills/${id}/unpublish`),
|
||||
getAdminSkillsStats: () => get<any>('/admin/skills/stats/overview'),
|
||||
// 智能体管理 (Phase 2.3)
|
||||
getAdminAgents: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/agents/${query}`);
|
||||
},
|
||||
getAdminAgent: (id: string) => get<any>(`/admin/agents/${id}`),
|
||||
createAdminAgent: (data: any) => post<any>('/admin/agents/', data),
|
||||
updateAdminAgent: (id: string, data: any) => put<any>(`/admin/agents/${id}`, data),
|
||||
deleteAdminAgent: (id: string) => del<any>(`/admin/agents/${id}`),
|
||||
toggleAdminAgent: (id: string, isActive: boolean) => put<any>(`/admin/agents/${id}/toggle`, { is_active: isActive }),
|
||||
getAdminAgentsStats: () => get<any>('/admin/agents/stats/overview'),
|
||||
getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`),
|
||||
getAdminAvailableSkills: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/agents/available/skills${query}`);
|
||||
},
|
||||
getAdminAvailableModels: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/agents/available/models${query}`);
|
||||
},
|
||||
// 知识库管理 (Phase 2.4)
|
||||
getAdminKnowledgeStats: () => get<any>('/admin/knowledge/stats/overview'),
|
||||
getAdminKnowledge: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/knowledge/${query}`);
|
||||
},
|
||||
getAdminKnowledgeDetail: (id: string) => get<any>(`/admin/knowledge/${id}`),
|
||||
createAdminKnowledge: (data: any) => post<any>('/admin/knowledge/', data),
|
||||
updateAdminKnowledge: (id: string, data: any) => put<any>(`/admin/knowledge/${id}`, data),
|
||||
deleteAdminKnowledge: (id: string) => del<any>(`/admin/knowledge/${id}`),
|
||||
toggleAdminKnowledge: (id: string, isActive: boolean) => put<any>(`/admin/knowledge/${id}/toggle`, { is_active: isActive }),
|
||||
getAdminKnowledgeDocs: (kbId: string, params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/knowledge/${kbId}/documents${query}`);
|
||||
},
|
||||
deleteAdminKnowledgeDoc: (kbId: string, docId: string) => del<any>(`/admin/knowledge/${kbId}/documents/${docId}`),
|
||||
getAdminKnowledgeExtServices: () => get<any>('/admin/knowledge/external/services'),
|
||||
createAdminKnowledgeExtService: (data: any) => post<any>('/admin/knowledge/external/services', data),
|
||||
updateAdminKnowledgeExtService: (id: string, data: any) => put<any>(`/admin/knowledge/external/services/${id}`, data),
|
||||
deleteAdminKnowledgeExtService: (id: string) => del<any>(`/admin/knowledge/external/services/${id}`),
|
||||
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
|
||||
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
||||
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
||||
// OSS存储配置 (Phase 3.1)
|
||||
getAssetStats: () => get<any>('/admin/assets/stats'),
|
||||
getAdminAssets: (query: string) => get<any>(`/admin/assets/?${query}`),
|
||||
deleteAdminAsset: (id: string) => del<any>(`/admin/assets/${id}`),
|
||||
getAssetActivity: () => get<any>('/admin/assets/activity'),
|
||||
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
|
||||
getAdminStorageConfigs: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/storage/${query}`);
|
||||
},
|
||||
getAdminStorageConfig: (id: string) => get<any>(`/admin/storage/${id}`),
|
||||
createAdminStorageConfig: (data: any) => post<any>('/admin/storage/', data),
|
||||
updateAdminStorageConfig: (id: string, data: any) => put<any>(`/admin/storage/${id}`, data),
|
||||
deleteAdminStorageConfig: (id: string) => del<any>(`/admin/storage/${id}`),
|
||||
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
|
||||
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
|
||||
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
|
||||
// Storage Quota APIs (Phase C5)
|
||||
getStorageQuotaOverview: () => get<any>('/admin/storage/quota/overview'),
|
||||
getStorageQuotaUsers: (params?: any) => {
|
||||
if (params) {
|
||||
const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&');
|
||||
return get<any>(`/admin/storage/quota/users${query ? '?' + query : ''}`);
|
||||
}
|
||||
return get<any>('/admin/storage/quota/users');
|
||||
},
|
||||
getStorageQuotaUser: (userId: string) => get<any>(`/admin/storage/quota/users/${userId}`),
|
||||
adjustStorageQuota: (userId: string, data: any) => put<any>(`/admin/storage/quota/users/${userId}`, data),
|
||||
recalcStorageQuota: (userId: string) => post<any>(`/admin/storage/quota/recalculate/${userId}`, {}),
|
||||
initAllStorageQuotas: () => post<any>('/admin/storage/quota/init-all', {}),
|
||||
|
||||
// 自动化任务调度 (Phase 3.2)
|
||||
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
|
||||
getAdminAutomationTasks: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/automation/${query}`);
|
||||
},
|
||||
getAdminAutomationTask: (id: string) => get<any>(`/admin/automation/${id}`),
|
||||
createAdminAutomationTask: (data: any) => post<any>('/admin/automation/', data),
|
||||
updateAdminAutomationTask: (id: string, data: any) => put<any>(`/admin/automation/${id}`, data),
|
||||
deleteAdminAutomationTask: (id: string) => del<any>(`/admin/automation/${id}`),
|
||||
toggleAdminAutomationTask: (id: string) => put<any>(`/admin/automation/${id}/toggle`),
|
||||
executeAdminAutomationTask: (id: string) => post<any>(`/admin/automation/${id}/execute`),
|
||||
getAdminAutomationTaskHistory: (id: string, params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/automation/${id}/history${query}`);
|
||||
},
|
||||
// MCP Server Management
|
||||
getAdminMcpStats: () => get<any>('/admin/mcp/stats/overview'),
|
||||
getAdminMcpServers: (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get<any>(`/admin/mcp/${query ? '?' + query : ''}`);
|
||||
},
|
||||
getAdminMcpServer: (id: string) => get<any>(`/admin/mcp/${id}`),
|
||||
createAdminMcpServer: (data: any) => post<any>('/admin/mcp/', data),
|
||||
updateAdminMcpServer: (id: string, data: any) => put<any>(`/admin/mcp/${id}`, data),
|
||||
deleteAdminMcpServer: (id: string) => del<any>(`/admin/mcp/${id}`),
|
||||
toggleAdminMcpServer: (id: string) => put<any>(`/admin/mcp/${id}/toggle`),
|
||||
testAdminMcpServer: (id: string, data?: any) => post<any>(`/admin/mcp/${id}/test`, data || {}),
|
||||
getAdminMcpServerTools: (id: string) => get<any>(`/admin/mcp/${id}/tools`),
|
||||
getAdminMcpServerResources: (id: string) => get<any>(`/admin/mcp/${id}/resources`),
|
||||
// Developer API Management
|
||||
getAdminDeveloperStats: () => get<any>('/admin/developer/stats/overview'),
|
||||
getAdminApiKeys: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
createAdminApiKey: (data: any) => post<any>('/admin/developer/api-keys/', data),
|
||||
updateAdminApiKey: (id: string, data: any) => put<any>(`/admin/developer/api-keys/${id}`, data),
|
||||
revokeAdminApiKey: (id: string) => post<any>(`/admin/developer/api-keys/${id}/revoke`),
|
||||
deleteAdminApiKey: (id: string) => del<any>(`/admin/developer/api-keys/${id}`),
|
||||
getAdminOpenApiClients: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
createAdminOpenApiClient: (data: any) => post<any>('/admin/developer/openapi-clients/', data),
|
||||
updateAdminOpenApiClient: (id: string, data: any) => put<any>(`/admin/developer/openapi-clients/${id}`, data),
|
||||
resetAdminOpenApiClientSecret: (id: string) => post<any>(`/admin/developer/openapi-clients/${id}/reset-secret`),
|
||||
deleteAdminOpenApiClient: (id: string) => del<any>(`/admin/developer/openapi-clients/${id}`),
|
||||
getAdminCallStats: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
getAdminCallStatsOverview: () => get<any>('/admin/developer/call-stats/overview'),
|
||||
getAdminSlowLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
|
||||
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
|
||||
// AI App Management
|
||||
getAIAppStats: () => get<any>('/admin/apps/stats/overview'),
|
||||
getAIApps: (params?: string) => get<any>('/admin/apps/apps' + (params ? '?' + params : '')),
|
||||
getAIApp: (id: string) => get<any>('/admin/apps/apps/' + id),
|
||||
createAIApp: (data: any) => post<any>('/admin/apps/apps', data),
|
||||
updateAIApp: (id: string, data: any) => put<any>('/admin/apps/apps/' + id, data),
|
||||
deleteAIApp: (id: string) => del<any>('/admin/apps/apps/' + id),
|
||||
aiAppLifecycle: (id: string, action: string) => post<any>('/admin/apps/apps/' + id + '/' + action, {}),
|
||||
getAIAppVersions: (id: string) => get<any>('/admin/apps/apps/' + id + '/versions'),
|
||||
getAIAppAnalytics: (id: string) => get<any>('/admin/apps/apps/' + id + '/analytics'),
|
||||
getAIAppTemplates: () => get<any>('/admin/apps/templates'),
|
||||
createAIAppTemplate: (data: any) => post<any>('/admin/apps/templates', data),
|
||||
createAIAppFromTemplate: (templateId: string, data: any) => post<any>('/admin/apps/apps/from-template/' + templateId, data),
|
||||
getAIAppCategories: () => get<any>('/admin/apps/categories'),
|
||||
// Logo 上传
|
||||
uploadLogo: (formData: FormData) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return fetch(`/app/api/admin/settings/logo/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
}).then(res => res.json());
|
||||
},
|
||||
|
||||
// MCP Market (Phase G1)
|
||||
getMcpMarketStats: () =>
|
||||
get<any>('/admin/mcp-market/stats'),
|
||||
getMcpTools: (params?: Record<string, any>) =>
|
||||
get<any>('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()),
|
||||
getMcpToolDetail: (id: string) =>
|
||||
get<any>('/admin/mcp-market/' + id),
|
||||
createMcpTool: (data: any) =>
|
||||
post<any>('/admin/mcp-market/', data),
|
||||
updateMcpTool: (id: string, data: any) =>
|
||||
put<any>('/admin/mcp-market/' + id, data),
|
||||
deleteMcpTool: (id: string) =>
|
||||
del<any>('/admin/mcp-market/' + id),
|
||||
scanMcpTool: (id: string) =>
|
||||
post<any>('/admin/mcp-market/' + id + '/security-scan'),
|
||||
updateMcpToolSecurity: (id: string, data: any) =>
|
||||
put<any>('/admin/mcp-market/' + id + '/security-status', data),
|
||||
regenMcpDeepLink: (id: string) =>
|
||||
post<any>('/admin/mcp-market/' + id + '/deep-link'),
|
||||
getMcpManagedServers: () =>
|
||||
get<any>('/admin/mcp-market/managed/servers'),
|
||||
seedMcpTools: () =>
|
||||
post<any>('/admin/mcp-market/seed'),
|
||||
|
||||
// Code Execution Sandbox (Phase G2)
|
||||
getCodeExecStats: () => get<any>('/admin/code-exec/stats'),
|
||||
getCodeExecRuntimes: () => get<any>('/admin/code-exec/runtimes'),
|
||||
executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) =>
|
||||
post<any>('/admin/code-exec/execute', data),
|
||||
getCodeExecTasks: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
|
||||
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
|
||||
|
||||
|
||||
// Distribution Builder (Phase G4)
|
||||
getDistributionStats: () => get<any>('/admin/distributions/stats'),
|
||||
getDistributions: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : ''));
|
||||
},
|
||||
getDistributionTemplates: () => get<any>('/admin/distributions/templates'),
|
||||
createDistribution: (data: any) => post<any>('/admin/distributions', data),
|
||||
getDistribution: (id: string) => get<any>('/admin/distributions/' + id),
|
||||
updateDistribution: (id: string, data: any) => put<any>('/admin/distributions/' + id, data),
|
||||
deleteDistribution: (id: string) => del<any>('/admin/distributions/' + id),
|
||||
getDistributionLicenses: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
|
||||
},
|
||||
createDistributionLicense: (distroId: string, data: any) => post<any>('/admin/distributions/' + distroId + '/licenses', data),
|
||||
getDistributionLicense: (id: string) => get<any>('/admin/distributions/licenses/' + id),
|
||||
revokeDistributionLicense: (id: string) => post<any>('/admin/distributions/licenses/' + id + '/revoke'),
|
||||
// ===== Ads Management =====
|
||||
getAds: () => get<any>('/admin/ads/'),
|
||||
addAd: (data: any) => post<any>('/admin/ads/', data),
|
||||
updateAd: (id: string, data: any) => put<any>(`/admin/ads/${id}`, data),
|
||||
deleteAd: (id: string) => del<any>(`/admin/ads/${id}`),
|
||||
|
||||
// ===== Market Admin (extended) =====
|
||||
getMarketOverview: () => get<any>('/admin/market/overview'),
|
||||
getTopApps: (metric?: string, limit?: number) => get<any>(`/admin/market/top-apps${metric ? '?metric=' + metric : ''}${limit ? (metric ? '&' : '?') + 'limit=' + limit : ''}`),
|
||||
getAdminMarketApps: (params?: string) => get<any>(`/admin/market/apps${params ? '?' + params : ''}`),
|
||||
getPendingApps: () => get<any>('/admin/market/pending'),
|
||||
getCoupons: () => get<any>('/admin/market/coupons'),
|
||||
getRecentReviews: () => get<any>('/admin/market/reviews'),
|
||||
approveApp: (id: string) => post<any>(`/admin/market/apps/${id}/approve`),
|
||||
rejectApp: (id: string) => post<any>(`/admin/market/apps/${id}/reject`),
|
||||
setFeaturedApp: (id: string, featured: boolean, sortOrder?: number) => put<any>(`/admin/market/apps/${id}/featured`, { featured, sort_order: sortOrder }),
|
||||
deleteMarketApp: (id: string) => del<any>(`/admin/market/apps/${id}`),
|
||||
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
|
||||
updateMarketApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
||||
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
|
||||
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
|
||||
|
||||
// ===== Membership (extended) =====
|
||||
getMembershipStats: () => get<any>('/admin/membership/stats'),
|
||||
revokeMembership: (userId: string) => post<any>(`/admin/membership/users/${userId}/revoke`),
|
||||
|
||||
// ===== Logs (extended) =====
|
||||
getOperationLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/operation-logs${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
getTokenLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/token-logs${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
|
||||
// ===== Analytics =====
|
||||
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview${days ? '?days=' + days : ''}`),
|
||||
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls${days ? '?days=' + days : ''}`),
|
||||
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens${days ? '?days=' + days : ''}`),
|
||||
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users${days ? '?days=' + days : ''}`),
|
||||
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue${days ? '?days=' + days : ''}`),
|
||||
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage${days ? '?days=' + days : ''}`),
|
||||
|
||||
// ===== Billing =====
|
||||
getBillingRules: () => get<any>('/admin/billing/rules'),
|
||||
getBillingPackages: () => get<any>('/admin/billing/packages'),
|
||||
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions${page ? '?page=' + page : ''}`),
|
||||
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
|
||||
|
||||
// ===== Tenant =====
|
||||
getTenants: (page?: number) => get<any>(`/admin/tenant/list${page ? '?page=' + page : ''}`),
|
||||
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
|
||||
getTenantMembers: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/members`),
|
||||
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
||||
|
||||
};
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
// 驾驶舱面板
|
||||
getSkillTree: () => get<import('@/types').SkillTreeData>('/evolution/dashboard/skill-tree'),
|
||||
getTimeline: (eventType?: string) =>
|
||||
get<import('@/types').EvolutionEvent[]>('/evolution/dashboard/timeline' + (eventType ? `?event_type=${eventType}` : '')),
|
||||
getStatistics: () => get<import('@/types').StatisticsData>('/evolution/dashboard/statistics'),
|
||||
getProfile: () => get<import('@/types').EvolutionProfile>('/evolution/dashboard/profile'),
|
||||
|
||||
// 技能管理
|
||||
getSkills: (params?: { state?: string; keyword?: string; sort_by?: string; order?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.state) query.append('state', params.state);
|
||||
if (params?.keyword) query.append('keyword', params.keyword);
|
||||
if (params?.sort_by) query.append('sort_by', params.sort_by);
|
||||
if (params?.order) query.append('order', params.order);
|
||||
const qs = query.toString();
|
||||
return get<import('@/types').EvolutionSkill[]>('/evolution/skills' + (qs ? `?${qs}` : ''));
|
||||
},
|
||||
getSkillDetail: (name: string) => get<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(name)}`),
|
||||
updateSkill: (name: string, data: { description?: string; tags?: string[]; category?: string }) =>
|
||||
put<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(name)}`, data),
|
||||
deleteSkill: (name: string) => del(`/evolution/skills/${encodeURIComponent(name)}`),
|
||||
freezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/freeze`),
|
||||
unfreezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/unfreeze`),
|
||||
getSkillHistory: (name: string) => get<import('@/types').EvolutionEvent[]>(`/evolution/skills/${encodeURIComponent(name)}/history`),
|
||||
getSkillTrajectories: (name: string) => get<import('@/types').EvolutionTrajectory[]>(`/evolution/skills/${encodeURIComponent(name)}/trajectories`),
|
||||
|
||||
// 事件
|
||||
getEvents: (params?: { event_type?: string; skill_name?: string; limit?: number }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.event_type) query.append('event_type', params.event_type);
|
||||
if (params?.skill_name) query.append('skill_name', params.skill_name);
|
||||
if (params?.limit) query.append('limit', String(params.limit));
|
||||
const qs = query.toString();
|
||||
return get<import('@/types').EvolutionEvent[]>('/evolution/events' + (qs ? `?${qs}` : ''));
|
||||
},
|
||||
|
||||
// 轨迹提交
|
||||
submitTrajectory: (data: { conversation_id: string; messages: any[] }) =>
|
||||
post('/evolution/trajectory/submit', data),
|
||||
|
||||
// v2: 技能回滚
|
||||
rollbackSkill: (skillName: string, version: number) =>
|
||||
post<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}/rollback/${version}`),
|
||||
|
||||
// v2: 发布到市场
|
||||
publishSkill: (skillName: string, data: { price: number; market_description: string }) =>
|
||||
post(`/evolution/skills/${encodeURIComponent(skillName)}/publish`, data),
|
||||
|
||||
// v2: 团队共享
|
||||
shareToTeam: (skillName: string, teamId: string) =>
|
||||
post(`/evolution/skills/${encodeURIComponent(skillName)}/share-to-team`, { team_id: teamId }),
|
||||
|
||||
// v2: 流水线状态
|
||||
getPipelineStatus: () => get<import('@/types').PipelineStatus>('/evolution/pipeline/status'),
|
||||
|
||||
// v2: 手动触发流水线
|
||||
triggerPipeline: () => post('/evolution/pipeline/trigger'),
|
||||
|
||||
// v2: Watcher状态
|
||||
getWatcherStatus: () => get<import('@/types').WatcherStatus>('/evolution/watcher/status'),
|
||||
|
||||
// v2: 进化报告
|
||||
getReports: (reportType?: string) => {
|
||||
const qs = reportType ? `?report_type=${reportType}` : '';
|
||||
return get<import('@/types').EvolutionReport[]>(`/evolution/reports${qs}`);
|
||||
},
|
||||
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
|
||||
|
||||
|
||||
// Workflow (Phase 3.6)
|
||||
getWorkflows: (params?: Record<string, any>) =>
|
||||
get<any>('/admin/workflows/?' + new URLSearchParams(params || {}).toString()),
|
||||
getWorkflow: (id: string) =>
|
||||
get<any>(`/admin/workflows/${id}`),
|
||||
getWorkflowStats: () =>
|
||||
get<any>('/admin/workflows/stats'),
|
||||
getWorkflowTemplates: () =>
|
||||
get<any>('/admin/workflows/templates'),
|
||||
createWorkflow: (data: any) =>
|
||||
post<any>('/admin/workflows/', data),
|
||||
updateWorkflow: (id: string, data: any) =>
|
||||
put<any>(`/admin/workflows/${id}`, data),
|
||||
deleteWorkflow: (id: string) =>
|
||||
del<any>(`/admin/workflows/${id}`),
|
||||
toggleWorkflow: (id: string, isActive: boolean) =>
|
||||
post<any>(`/admin/workflows/${id}/toggle?is_active=${isActive}`),
|
||||
executeWorkflow: (id: string, inputText: string) =>
|
||||
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
|
||||
|
||||
|
||||
// Privacy & Local Agent (Phase G5)
|
||||
getPrivacyStats: () => get<any>('/admin/privacy/stats'),
|
||||
getPrivacyConfig: () => get<any>('/admin/privacy/config'),
|
||||
updatePrivacyConfig: (params: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); });
|
||||
return put<any>('/admin/privacy/config?' + sp.toString());
|
||||
},
|
||||
switchPrivacyMode: (mode: string) => post<any>('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)),
|
||||
getPrivacyLicenses: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.status) sp.append('status', params.status);
|
||||
return get<any>('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
|
||||
},
|
||||
generateOfflineLicense: (data: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (data.agent_id) sp.append('agent_id', data.agent_id);
|
||||
sp.append('max_offline_days', String(data.max_offline_days || 7));
|
||||
return post<any>('/admin/privacy/licenses/generate?' + sp.toString());
|
||||
},
|
||||
revokePrivacyLicense: (id: number) => post<any>('/admin/privacy/licenses/' + id + '/revoke'),
|
||||
validatePrivacyLicense: (licenseKey: string) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.append('license_key', licenseKey);
|
||||
return post<any>('/admin/privacy/licenses/validate?' + sp.toString());
|
||||
},
|
||||
getKbSyncStates: () => get<any>('/admin/privacy/kb-sync'),
|
||||
triggerKbSync: (kbId: number, direction: string) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.append('knowledge_base_id', String(kbId));
|
||||
sp.append('sync_direction', direction);
|
||||
return post<any>('/admin/privacy/kb-sync/trigger?' + sp.toString());
|
||||
},
|
||||
getLocalAgents: () => get<any>('/admin/privacy/local-agents'),
|
||||
getPrivacyUsageStats: () => get<any>('/admin/privacy/usage/stats'),
|
||||
getBridgeConfig: () => get<any>('/admin/privacy/bridge-config'),
|
||||
|
||||
// IM Bot Management (Phase 4)
|
||||
getImBotStats: () => get('/admin/im-bot/stats'),
|
||||
getImBotDashboard: () => get('/admin/im-bot/dashboard'),
|
||||
getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`),
|
||||
getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`),
|
||||
testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`),
|
||||
activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`),
|
||||
deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`),
|
||||
getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`),
|
||||
createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`),
|
||||
deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`),
|
||||
getLangbotConfig: () => get('/admin/im-bot/langbot'),
|
||||
updateLangbotConfig: (params: string) => post(`/admin/im-bot/langbot${params}`),
|
||||
testLangbot: (params: string) => post(`/admin/im-bot/langbot/test${params}`),
|
||||
getImBotPlatforms: () => get('/admin/im-bot/platforms'),
|
||||
getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`),
|
||||
getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`),
|
||||
imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`),
|
||||
|
||||
// 推广系统
|
||||
getPromotionStats: () => get<any>('/admin/promotion/stats'),
|
||||
getPromotions: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
createPromotion: (params: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
|
||||
return post<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
updatePromotion: (id: string, params: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
|
||||
return put<any>(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
deletePromotion: (id: string) => del<any>(`/admin/promotion/promotions/${id}`),
|
||||
togglePromotion: (id: string) => post<any>(`/admin/promotion/promotions/${id}/toggle`),
|
||||
getReferralLinks: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/promotion/referral-links${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
generateReferralLink: (params: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return post<any>(`/admin/promotion/referral-links/generate${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getReferralRewards: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
approveReward: (id: string) => post<any>(`/admin/promotion/rewards/${id}/approve`),
|
||||
markRewardPaid: (id: string, params: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return post<any>(`/admin/promotion/rewards/${id}/pay${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getPromotionLeaderboard: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
regenerateReferralCode: (id: string) => post<any>(`/admin/promotion/referral-links/${id}/regenerate`),
|
||||
getMyReferrals: () => get<any>('/admin/promotion/my-referrals'),
|
||||
getMyRewards: () => get<any>('/admin/promotion/my-rewards'),
|
||||
|
||||
// 成就系统
|
||||
getAchievements: (params?: string) => get<any>(`/admin/achievement/achievements${params || ''}`),
|
||||
getAchievementDetail: (id: string) => get<any>(`/admin/achievement/achievements/${id}`),
|
||||
createAchievement: (params: string) => post<any>(`/admin/achievement/achievements${params}`),
|
||||
updateAchievement: (id: string, params: string) => put<any>(`/admin/achievement/achievements/${id}${params}`),
|
||||
deleteAchievement: (id: string) => del<any>(`/admin/achievement/achievements/${id}`),
|
||||
getUserAchievements: () => get<any>('/admin/achievement/user/achievements'),
|
||||
claimAchievementReward: (id: string) => post<any>(`/admin/achievement/user/achievements/${id}/claim`),
|
||||
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
|
||||
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
|
||||
|
||||
// 多租户管理
|
||||
getTenants: (page?: number, keyword?: string, status?: string) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (page) sp.set('page', String(page));
|
||||
if (keyword) sp.set('keyword', keyword);
|
||||
if (status) sp.set('status', status);
|
||||
sp.set('page_size', '20');
|
||||
return get<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
||||
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
|
||||
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
|
||||
deleteTenant: (id: string) => del<any>(`/admin/tenant/${id}`),
|
||||
getTenantMembers: (tenantId: string, page?: number) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(page || 1));
|
||||
sp.set('page_size', '100');
|
||||
return get<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
|
||||
},
|
||||
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
|
||||
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
|
||||
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
|
||||
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
|
||||
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
|
||||
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
|
||||
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
|
||||
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
|
||||
|
||||
// 计费与支付
|
||||
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
|
||||
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
|
||||
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
|
||||
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
|
||||
getBillingPackages: () => get<any>('/admin/billing/packages'),
|
||||
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
|
||||
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
|
||||
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
|
||||
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
|
||||
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
|
||||
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
|
||||
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
|
||||
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
|
||||
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
|
||||
|
||||
// 数据分析
|
||||
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
|
||||
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
|
||||
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
|
||||
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
|
||||
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
|
||||
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
|
||||
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
|
||||
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
|
||||
deleteAnalyticsDashboard: (id: string) => del<any>(`/admin/analytics/dashboards/${id}`),
|
||||
getAnalyticsEvents: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
/* ===== Ads Management ===== */
|
||||
|
||||
.ads-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 6vh;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
.ads-modal {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-2xl);
|
||||
width: 580px;
|
||||
max-height: 84vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.ads-modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
/* Preview Card */
|
||||
.ads-form-group {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.ads-preview-card {
|
||||
padding: var(--space-2xl);
|
||||
border-radius: var(--radius-lg);
|
||||
color: #FFFFFF;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.ads-preview-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.ads-preview-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-xs);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ads-preview-sub {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ads-preview-visual {
|
||||
position: absolute;
|
||||
right: var(--space-2xl);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.ads-vc {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.ads-vc1 {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.ads-vc2 {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.ads-vc3 {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI, marketAPI } from '@/api';
|
||||
import type { AdBanner, MarketApp } from '@/types';
|
||||
import './Ads.css';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
|
||||
|
||||
const Ads: React.FC = () => {
|
||||
const [banners, setBanners] = useState<AdBanner[]>([]);
|
||||
const [appOptions, setAppOptions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingBanner, setEditingBanner] = useState<AdBanner | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genPrompt, setGenPrompt] = useState('');
|
||||
|
||||
const emptyForm: AdBanner = {
|
||||
id: '', title: '', imageUrl: '',
|
||||
linkAppId: '', linkAppName: '',
|
||||
sortOrder: banners.length + 1, clickCount: 0,
|
||||
status: 'active', createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<AdBanner>(emptyForm);
|
||||
|
||||
const loadBanners = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await adminAPI.getAds();
|
||||
const data = Array.isArray(res) ? res : (res.data || []);
|
||||
setBanners(data.map((a: any) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
imageUrl: a.image_url || a.imageUrl || '',
|
||||
linkAppId: a.link_app_id || a.linkAppId || '',
|
||||
linkAppName: a.link_app_name || a.linkAppName || '',
|
||||
sortOrder: a.sort_order ?? a.sortOrder ?? 0,
|
||||
clickCount: a.click_count ?? a.clickCount ?? 0,
|
||||
status: a.status || 'active',
|
||||
createdAt: a.created_at || a.createdAt || new Date().toISOString(),
|
||||
})));
|
||||
} catch (e) {
|
||||
console.error('加载广告失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBanners();
|
||||
// 加载应用列表用于关联选择
|
||||
marketAPI.getApps({}).then((res: any) => {
|
||||
const items = res.data?.items || [];
|
||||
setAppOptions(items);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setIsNew(true);
|
||||
setForm({ ...emptyForm, sortOrder: banners.length + 1 });
|
||||
setEditingBanner(null);
|
||||
setGenPrompt('');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (banner: AdBanner) => {
|
||||
setIsNew(false);
|
||||
setEditingBanner(banner);
|
||||
setForm({ ...banner });
|
||||
setGenPrompt('');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (isNew) {
|
||||
await adminAPI.addAd({
|
||||
title: form.title,
|
||||
image_url: form.imageUrl || undefined,
|
||||
link_app_id: form.linkAppId || undefined,
|
||||
link_app_name: form.linkAppName || undefined,
|
||||
sort_order: form.sortOrder,
|
||||
status: form.status,
|
||||
});
|
||||
} else if (editingBanner) {
|
||||
await adminAPI.updateAd(editingBanner.id, {
|
||||
title: form.title,
|
||||
image_url: form.imageUrl || undefined,
|
||||
link_app_id: form.linkAppId || undefined,
|
||||
link_app_name: form.linkAppName || undefined,
|
||||
sort_order: form.sortOrder,
|
||||
status: form.status,
|
||||
});
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingBanner(null);
|
||||
loadBanners();
|
||||
} catch (err: any) {
|
||||
alert('保存失败:' + (err.detail || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('确定要删除此广告吗?')) return;
|
||||
try {
|
||||
await adminAPI.deleteAd(id);
|
||||
loadBanners();
|
||||
} catch (err: any) {
|
||||
alert('删除失败:' + (err.detail || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (id: string, currentStatus: string) => {
|
||||
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||
try {
|
||||
await adminAPI.updateAd(id, { status: newStatus });
|
||||
loadBanners();
|
||||
} catch (err: any) {
|
||||
alert('切换状态失败:' + (err.detail || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppSelect = (appId: string) => {
|
||||
const app = appOptions.find(a => a.id === appId);
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
linkAppId: appId,
|
||||
linkAppName: app ? app.name : '',
|
||||
}));
|
||||
};
|
||||
|
||||
// 真实图片上传
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const res = await fetch('/app/api/admin/ads/upload-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '上传失败' }));
|
||||
throw new Error(err.detail || '上传失败');
|
||||
}
|
||||
const data = await res.json();
|
||||
setForm({ ...form, imageUrl: data.image_url });
|
||||
} catch (err: any) {
|
||||
alert('图片上传失败:' + err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// AI生成图片
|
||||
const handleAIGenerate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const prompt = genPrompt || form.title || 'AI智能平台广告横幅';
|
||||
const res = await fetch(`/app/api/admin/ads/generate-image?prompt=${encodeURIComponent(prompt)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '生成失败' }));
|
||||
throw new Error(err.detail || '生成失败');
|
||||
}
|
||||
const data = await res.json();
|
||||
setForm({ ...form, imageUrl: data.image_url });
|
||||
} catch (err: any) {
|
||||
alert('AI绘图失败:' + err.message);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成渐变色预览色块
|
||||
const getPreviewGradient = (appId?: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
a1: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
a2: 'linear-gradient(135deg, #f093fb, #f5576c)',
|
||||
a3: 'linear-gradient(135deg, #4facfe, #00f2fe)',
|
||||
a4: 'linear-gradient(135deg, #43e97b, #38f9d7)',
|
||||
a5: 'linear-gradient(135deg, #fa709a, #fee140)',
|
||||
a6: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
|
||||
a8: 'linear-gradient(135deg, #ffecd2, #fcb69f)',
|
||||
};
|
||||
return gradients[appId || ''] || 'linear-gradient(135deg, #5B5FED, #A78BFA)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-xl)' }}>
|
||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>广告位管理</h1>
|
||||
<button className="admin-btn admin-btn-primary" onClick={handleCreate}>
|
||||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
新增广告位
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Banner List */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : banners.length === 0 ? (
|
||||
<div className="admin-card" style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
|
||||
暂无广告位,点击"新增广告位"按钮创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">轮播图广告位列表</h3>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
|
||||
共 {banners.length} 个广告位,活跃 {banners.filter(b => b.status === 'active').length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>预览</th>
|
||||
<th>标题</th>
|
||||
<th>关联应用</th>
|
||||
<th>排序</th>
|
||||
<th>点击次数</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{banners.sort((a, b) => a.sortOrder - b.sortOrder).map(banner => (
|
||||
<tr key={banner.id}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
width: 48, height: 32, borderRadius: 'var(--radius-sm)',
|
||||
background: getPreviewGradient(banner.linkAppId),
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SVGIcon size={14}><rect x="1" y="3" width="22" height="16" rx="2"/><polyline points="23 7 13 17 8 12"/></SVGIcon>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{banner.title}</td>
|
||||
<td>
|
||||
{banner.linkAppName ? (
|
||||
<span className="admin-badge badge-info" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{banner.linkAppName}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>未关联</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>{banner.sortOrder}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<SVGIcon size={12}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></SVGIcon>
|
||||
{banner.clickCount.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${banner.status === 'active' ? 'badge-success' : 'badge-neutral'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleStatus(banner.id, banner.status)}
|
||||
>
|
||||
{banner.status === 'active' ? '投放中' : '已暂停'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
|
||||
{new Date(banner.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(banner)}>
|
||||
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ color: 'var(--color-danger)', borderColor: 'var(--color-danger)' }}
|
||||
onClick={() => handleDelete(banner.id)}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit/Create Modal */}
|
||||
{showModal && (
|
||||
<div
|
||||
className="ads-modal-overlay"
|
||||
onClick={() => { setShowModal(false); setEditingBanner(null); }}
|
||||
>
|
||||
<div className="ads-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="ads-modal-title">
|
||||
{isNew ? '新增广告位' : `编辑 - ${editingBanner?.title}`}
|
||||
</h3>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="ads-form-group">
|
||||
<label className="admin-form-label">广告位预览</label>
|
||||
<div
|
||||
className="ads-preview-card"
|
||||
style={{ background: getPreviewGradient(form.linkAppId) }}
|
||||
>
|
||||
<div className="ads-preview-badge">推荐</div>
|
||||
<h4 className="ads-preview-title">{form.title || '广告标题'}</h4>
|
||||
{form.linkAppName && (
|
||||
<p className="ads-preview-sub">关联应用: {form.linkAppName}</p>
|
||||
)}
|
||||
<div className="ads-preview-visual">
|
||||
<div className="ads-vc ads-vc1"/>
|
||||
<div className="ads-vc ads-vc2"/>
|
||||
<div className="ads-vc ads-vc3"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">广告标题</label>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="输入轮播图展示标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image URL + Upload + AIGC */}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">背景图片</label>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-sm)' }}>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
style={{ flex: 1 }}
|
||||
value={form.imageUrl}
|
||||
onChange={e => setForm({ ...form, imageUrl: e.target.value })}
|
||||
placeholder="图片URL / 上传后自动填入 / 留空使用渐变色"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<SVGIcon size={12}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
|
||||
{uploading ? '上传中...' : '上传图片'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
style={{ width: 180, fontSize: 'var(--text-xs)' }}
|
||||
value={genPrompt}
|
||||
onChange={e => setGenPrompt(e.target.value)}
|
||||
placeholder="AI绘图提示词..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap' }}
|
||||
onClick={handleAIGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
<SVGIcon size={12}><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></SVGIcon>
|
||||
{generating ? '生成中...' : 'AI绘图'}
|
||||
</button>
|
||||
</div>
|
||||
{form.imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ color: 'var(--color-error)', borderColor: 'var(--color-error)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
onClick={() => setForm({ ...form, imageUrl: '' })}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
清除图片
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.imageUrl && (
|
||||
<div style={{ marginTop: 'var(--space-sm)', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)', maxWidth: 320, height: 80 }}>
|
||||
<img src={form.imageUrl} alt="预览" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link App */}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">关联应用(点击跳转)</label>
|
||||
<select
|
||||
className="admin-form-input"
|
||||
value={form.linkAppId || ''}
|
||||
onChange={e => handleAppSelect(e.target.value)}
|
||||
>
|
||||
<option value="">-- 不关联应用 --</option>
|
||||
{appOptions.map((app: any) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
[{typeLabels[app.type] || 'app'}] {app.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">排序</label>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">状态</label>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', paddingTop: 4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="adStatus"
|
||||
checked={form.status === 'active'}
|
||||
onChange={() => setForm({ ...form, status: 'active' })}
|
||||
/> 投放中
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="adStatus"
|
||||
checked={form.status === 'inactive'}
|
||||
onChange={() => setForm({ ...form, status: 'inactive' })}
|
||||
/> 已暂停
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!form.title.trim()}
|
||||
>
|
||||
{isNew ? '创建' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline"
|
||||
onClick={() => { setShowModal(false); setEditingBanner(null); }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ads;
|
||||
@@ -0,0 +1,702 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
source: string;
|
||||
publisher_id: string;
|
||||
publisher_name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
tutorial?: string;
|
||||
price: number;
|
||||
is_member_free: boolean;
|
||||
join_promotion: boolean;
|
||||
status: string;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
install_count: number;
|
||||
skill_content?: string;
|
||||
agent_endpoint?: string;
|
||||
config?: AgentConfig;
|
||||
linked_skills?: LinkedSkill[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AgentConfig {
|
||||
system_prompt?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
model_id?: string;
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
memory_enabled?: boolean;
|
||||
reasoning_enabled?: boolean;
|
||||
}
|
||||
|
||||
interface LinkedSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
state: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface AgentStats {
|
||||
total_agents: number;
|
||||
published_agents: number;
|
||||
pending_agents: number;
|
||||
official_agents: number;
|
||||
user_agents: number;
|
||||
total_installs: number;
|
||||
average_rating: number;
|
||||
by_source: Record<string, number>;
|
||||
}
|
||||
|
||||
interface AvailableModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model_type?: string;
|
||||
}
|
||||
|
||||
const AGENT_TYPES = [
|
||||
{ id: 'skill', label: '技能' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
{ id: 'mcp', label: 'MCP' },
|
||||
];
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<AgentStats | null>(null);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
|
||||
// Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
price: '0',
|
||||
is_member_free: false,
|
||||
system_prompt: '',
|
||||
temperature: '0.7',
|
||||
max_tokens: '2048',
|
||||
model_id: '',
|
||||
skills: [] as string[],
|
||||
memory_enabled: false,
|
||||
reasoning_enabled: false,
|
||||
});
|
||||
|
||||
// Detail drawer
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Available skills for linking
|
||||
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load agents
|
||||
const loadAgents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: 15 };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSource) params.source = filterSource;
|
||||
const result = await adminAPI.getAdminAgents(params) as any;
|
||||
const data = result.data || result;
|
||||
setAgents(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAgentsStats() as any;
|
||||
const data = result.data || result;
|
||||
setStats(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Load available skills & models
|
||||
const loadAvailable = async () => {
|
||||
try {
|
||||
const [skillsResult, modelsResult] = await Promise.all([
|
||||
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
||||
adminAPI.getAdminAvailableModels() as any,
|
||||
]);
|
||||
const skills = (skillsResult.data || skillsResult).items || [];
|
||||
const models = (modelsResult.data || modelsResult) || [];
|
||||
setAvailableSkills(skills);
|
||||
setAvailableModels(models);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
}, [page, keyword, filterStatus, filterSource]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// Search
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadAgents();
|
||||
};
|
||||
|
||||
// Open create
|
||||
const openCreate = async () => {
|
||||
setEditingAgent(null);
|
||||
await loadAvailable();
|
||||
setForm({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
price: '0',
|
||||
is_member_free: false,
|
||||
system_prompt: '你是一个智能助手。',
|
||||
temperature: '0.7',
|
||||
max_tokens: '2048',
|
||||
model_id: '',
|
||||
skills: [],
|
||||
memory_enabled: false,
|
||||
reasoning_enabled: false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open edit
|
||||
const openEdit = async (agent: Agent) => {
|
||||
setEditingAgent(agent);
|
||||
await loadAvailable();
|
||||
|
||||
const cfg = agent.config || {};
|
||||
setForm({
|
||||
name: agent.name,
|
||||
description: agent.description || '',
|
||||
icon: agent.icon || '',
|
||||
price: String(agent.price),
|
||||
is_member_free: agent.is_member_free,
|
||||
system_prompt: cfg.system_prompt || '',
|
||||
temperature: String(cfg.temperature || 0.7),
|
||||
max_tokens: String(cfg.max_tokens || 2048),
|
||||
model_id: cfg.model_id || '',
|
||||
skills: cfg.skills || [],
|
||||
memory_enabled: cfg.memory_enabled || false,
|
||||
reasoning_enabled: cfg.reasoning_enabled || false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open detail
|
||||
const openDetail = async (agent: Agent) => {
|
||||
setDetailAgent(agent);
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
// Save agent
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
showToast('Agent 名称不能为空', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
icon: form.icon || undefined,
|
||||
price: parseFloat(form.price) || 0,
|
||||
is_member_free: form.is_member_free,
|
||||
config: {
|
||||
system_prompt: form.system_prompt,
|
||||
temperature: parseFloat(form.temperature) || 0.7,
|
||||
max_tokens: parseInt(form.max_tokens) || 2048,
|
||||
model_id: form.model_id || undefined,
|
||||
skills: form.skills,
|
||||
memory_enabled: form.memory_enabled,
|
||||
reasoning_enabled: form.reasoning_enabled,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingAgent) {
|
||||
await adminAPI.updateAdminAgent(editingAgent.id, payload);
|
||||
showToast('Agent 已更新');
|
||||
} else {
|
||||
await adminAPI.createAdminAgent(payload);
|
||||
showToast('Agent 创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle agent
|
||||
const handleToggle = async (agent: Agent) => {
|
||||
const isActive = agent.status === 'published';
|
||||
try {
|
||||
await adminAPI.toggleAdminAgent(agent.id, !isActive);
|
||||
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`操作失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete agent
|
||||
const handleDelete = async (agent: Agent) => {
|
||||
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminAgent(agent.id);
|
||||
showToast('Agent 已删除');
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle skill in selection
|
||||
const toggleSkill = (skillId: string) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
skills: f.skills.includes(skillId)
|
||||
? f.skills.filter(s => s !== skillId)
|
||||
: [...f.skills, skillId],
|
||||
}));
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
|
||||
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
|
||||
return { bg: '#FEE2E2', color: '#EF4444' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="toast" style={{
|
||||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||||
padding: '12px 20px', borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Agent 管理</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||||
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn primary" onClick={openCreate}>+ 创建 Agent</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #6366F1' }}>
|
||||
<div className="stat-value">{stats.total_agents}</div>
|
||||
<div className="stat-label">Agent 总数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #10B981' }}>
|
||||
<div className="stat-value">{stats.published_agents}</div>
|
||||
<div className="stat-label">已发布</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #F59E0B' }}>
|
||||
<div className="stat-value">{stats.pending_agents}</div>
|
||||
<div className="stat-label">待发布</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||||
<div className="stat-value">{stats.total_installs.toLocaleString()}</div>
|
||||
<div className="stat-label">总安装量</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||||
<div className="stat-value">{stats.average_rating.toFixed(1)}</div>
|
||||
<div className="stat-label">平均评分</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #EC4899' }}>
|
||||
<div className="stat-value">{stats.official_agents} / {stats.user_agents}</div>
|
||||
<div className="stat-label">官方 / 用户</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<input className="admin-form-input" placeholder="搜索名称、描述..."
|
||||
value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
style={{ maxWidth: 280 }} />
|
||||
<select className="admin-form-input" value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
|
||||
style={{ maxWidth: 140 }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="pending">待发布</option>
|
||||
<option value="removed">已下架</option>
|
||||
</select>
|
||||
<select className="admin-form-input" value={filterSource} onChange={e => { setFilterSource(e.target.value); setPage(1); }}
|
||||
style={{ maxWidth: 140 }}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="official">官方</option>
|
||||
<option value="user">用户</option>
|
||||
</select>
|
||||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||||
{(keyword || filterStatus || filterSource) && (
|
||||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterStatus(''); setFilterSource(''); setPage(1); }}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>智能体</th>
|
||||
<th>类型</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th>价格</th>
|
||||
<th>安装量</th>
|
||||
<th>评分</th>
|
||||
<th>更新时间</th>
|
||||
<th style={{ width: 240 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||||
) : agents.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无 Agent</td></tr>
|
||||
) : agents.map(agent => {
|
||||
const sc = statusColor(agent.status);
|
||||
return (
|
||||
<tr key={agent.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{agent.icon ? (
|
||||
<img src={agent.icon} alt="" style={{ width: 32, height: 32, borderRadius: 8, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#EEF2FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{agent.name}</div>
|
||||
{agent.description && (
|
||||
<div style={{ fontSize: 12, color: '#6B7280', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 10, background: '#EEF2FF', color: '#6366F1' }}>
|
||||
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: 12, color: agent.source === 'official' ? '#10B981' : '#6B7280' }}>
|
||||
{agent.source === 'official' ? '官方' : '用户'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12, background: sc.bg, color: sc.color, fontWeight: 500 }}>
|
||||
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{agent.price === 0 ? <span style={{ color: '#10B981' }}>免费</span> : `¥${agent.price}`}</td>
|
||||
<td>{agent.install_count.toLocaleString()}</td>
|
||||
<td>{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'}</td>
|
||||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||||
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button className="admin-btn small" onClick={() => openDetail(agent)}>详情</button>
|
||||
<button className="admin-btn small" onClick={() => openEdit(agent)}>编辑</button>
|
||||
<button
|
||||
className={`admin-btn small ${agent.status === 'published' ? 'warning' : 'success'}`}
|
||||
onClick={() => handleToggle(agent)}
|
||||
>
|
||||
{agent.status === 'published' ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button className="admin-btn small danger" onClick={() => handleDelete(agent)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span style={{ color: '#6B7280', fontSize: 13 }}>共 {total} 条,第 {page}/{totalPages} 页</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||||
return p <= totalPages && (
|
||||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`} onClick={() => setPage(p)}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{editingAgent ? '编辑 Agent' : '创建 Agent'}</h3>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 *</label>
|
||||
<input className="admin-form-input" value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="例如: 智能代码审查助手" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">图标 URL</label>
|
||||
<input className="admin-form-input" value={form.icon}
|
||||
onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
|
||||
placeholder="https://..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">价格(元,0=免费)</label>
|
||||
<input className="admin-form-input" type="number" value={form.price}
|
||||
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.is_member_free}
|
||||
onChange={e => setForm(f => ({ ...f, is_member_free: e.target.checked }))} />
|
||||
会员免费
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">描述</label>
|
||||
<textarea className="admin-form-input" rows={2} value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="描述 Agent 的功能和使用场景..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>⚙️ Agent 配置</div>
|
||||
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">系统提示词</label>
|
||||
<textarea className="admin-form-input"
|
||||
value={form.system_prompt}
|
||||
onChange={e => setForm(f => ({ ...f, system_prompt: e.target.value }))}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 120 }}
|
||||
placeholder="定义 Agent 的角色、行为和能力..." />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">模型</label>
|
||||
<select className="admin-form-input" value={form.model_id}
|
||||
onChange={e => setForm(f => ({ ...f, model_id: e.target.value }))}>
|
||||
<option value="">默认模型</option>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name} ({m.provider})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">温度参数</label>
|
||||
<input className="admin-form-input" type="number" step="0.1" min="0" max="2"
|
||||
value={form.temperature}
|
||||
onChange={e => setForm(f => ({ ...f, temperature: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">最大令牌数</label>
|
||||
<input className="admin-form-input" type="number"
|
||||
value={form.max_tokens}
|
||||
onChange={e => setForm(f => ({ ...f, max_tokens: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.memory_enabled}
|
||||
onChange={e => setForm(f => ({ ...f, memory_enabled: e.target.checked }))} />
|
||||
记忆能力
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.reasoning_enabled}
|
||||
onChange={e => setForm(f => ({ ...f, reasoning_enabled: e.target.checked }))} />
|
||||
推理能力
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Linked Skills */}
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">关联技能(可多选)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||||
{availableSkills.length === 0 ? (
|
||||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
|
||||
) : availableSkills.map(skill => (
|
||||
<label key={skill.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
|
||||
borderRadius: 12, fontSize: 12, cursor: 'pointer',
|
||||
background: form.skills.includes(skill.id) ? '#EEF2FF' : '#F9FAFB',
|
||||
border: `1px solid ${form.skills.includes(skill.id) ? '#6366F1' : '#E5E7EB'}`,
|
||||
color: form.skills.includes(skill.id) ? '#6366F1' : '#374151',
|
||||
}}>
|
||||
<input type="checkbox" style={{ display: 'none' }}
|
||||
checked={form.skills.includes(skill.id)}
|
||||
onChange={() => toggleSkill(skill.id)} />
|
||||
{skill.skill_name}
|
||||
{skill.state !== 'baby' && (
|
||||
<span style={{ fontSize: 10, background: '#E5E7EB', padding: '1px 4px', borderRadius: 4, marginLeft: 4 }}>
|
||||
{skill.state}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{form.skills.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: '#6366F1' }}>
|
||||
已选 {form.skills.length} 个技能
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button className="admin-btn primary" onClick={handleSave}>
|
||||
{editingAgent ? '保存更改' : '创建 Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{showDetail && detailAgent && (
|
||||
<div className="modal-overlay" onClick={() => setShowDetail(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 600 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{detailAgent.name}</h3>
|
||||
<button className="modal-close" onClick={() => setShowDetail(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
<div className="detail-item"><span className="detail-label">类型</span><span>{detailAgent.type}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">来源</span><span>{detailAgent.source === 'official' ? '官方' : '用户'}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">状态</span><span>{detailAgent.status}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">价格</span><span>¥{detailAgent.price}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">安装量</span><span>{detailAgent.install_count}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">评分</span><span>{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">发布者</span><span>{detailAgent.publisher_name || detailAgent.publisher_id}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">创建时间</span><span>{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}</span></div>
|
||||
</div>
|
||||
|
||||
{detailAgent.description && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>描述</div>
|
||||
<p style={{ color: '#6B7280', fontSize: 13, margin: 0 }}>{detailAgent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailAgent.config && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>⚙️ 配置</div>
|
||||
<div style={{ background: '#F9FAFB', borderRadius: 8, padding: 12, fontSize: 13 }}>
|
||||
{detailAgent.config.system_prompt && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: '#6B7280' }}>System Prompt:</span>
|
||||
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{detailAgent.config.system_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
|
||||
<span>Temperature: <code>{detailAgent.config.temperature}</code></span>
|
||||
<span>Max Tokens: <code>{detailAgent.config.max_tokens}</code></span>
|
||||
<span>Model: <code>{detailAgent.config.model_id || '默认'}</code></span>
|
||||
<span>记忆: <code>{detailAgent.config.memory_enabled ? '✓' : '✗'}</code></span>
|
||||
<span>推理: <code>{detailAgent.config.reasoning_enabled ? '✓' : '✗'}</code></span>
|
||||
<span>关联技能: <code>{detailAgent.config.skills?.length || 0} 个</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||||
.modal-body { padding: 24px; overflow-y: auto; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||||
.detail-item { display: flex; gap: 8px; font-size: 13px; }
|
||||
.detail-label { color: #6B7280; min-width: 60px; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Agents;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Overview {
|
||||
total_users: number; active_users: number; new_users: number;
|
||||
api_calls: number; avg_response_time: number; error_rate: number;
|
||||
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
|
||||
}
|
||||
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
|
||||
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
|
||||
|
||||
const Analytics: React.FC = () => {
|
||||
const [days, setDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
|
||||
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
|
||||
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
|
||||
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
|
||||
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, api, tok, usr, rev, mu] = await Promise.all([
|
||||
adminAPI.getAnalyticsOverview(days),
|
||||
adminAPI.getAnalyticsApiTrends(days),
|
||||
adminAPI.getAnalyticsTokenTrends(days),
|
||||
adminAPI.getAnalyticsUserTrends(days),
|
||||
adminAPI.getAnalyticsRevenueTrends(days),
|
||||
adminAPI.getAnalyticsModelUsage(days),
|
||||
]);
|
||||
setOverview((ov as any)?.data || ov);
|
||||
setApiTrend((api as any)?.data || []);
|
||||
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
|
||||
setTokenTrend(td);
|
||||
setUserTrend((usr as any)?.data || []);
|
||||
setRevenueTrend((rev as any)?.data || []);
|
||||
setModelUsage((mu as any)?.data || []);
|
||||
} catch (e) { console.error('fetchAnalytics:', e); }
|
||||
setLoading(false);
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||
const values = data.map((d: any) => d[key] || 0);
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
|
||||
{data.map((d: any, i) => {
|
||||
const v = d[key] || 0;
|
||||
const h = (v / max) * 140;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#8892B0', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
||||
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
|
||||
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||
const values = data.map((d: any) => d[key] || 0);
|
||||
const max = Math.max(...values, 1);
|
||||
const w = 100 / (data.length - 1 || 1);
|
||||
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
|
||||
{data.map((d: any, i) => (
|
||||
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const statCards = overview ? [
|
||||
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
|
||||
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
||||
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
||||
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
||||
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
|
||||
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
|
||||
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
|
||||
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: '#1A1F36' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{[7, 14, 30, 90].map(d => (
|
||||
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
|
||||
<>
|
||||
{/* Stats grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{statCards.map((s, i) => (
|
||||
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
|
||||
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
||||
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
||||
</div>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
||||
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
||||
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
||||
</div>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
||||
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model usage */}
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
||||
{modelUsage.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
|
||||
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{modelUsage.map((m, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#8892B0' }}>{m.avg_tokens.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
|
||||
</div>
|
||||
|
||||
{/* Model distribution pie (CSS) */}
|
||||
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
||||
{(() => {
|
||||
const entries = Object.entries(tokenTrend.model_distribution);
|
||||
const total = entries.reduce((s, [, v]) => s + v, 1);
|
||||
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||
return entries.map(([model, val], i) => {
|
||||
const pct = (val / total) * 100;
|
||||
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
||||
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
||||
return (
|
||||
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
||||
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
|
||||
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
@@ -0,0 +1,519 @@
|
||||
.admin-apps-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-apps-page .admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-apps-page .admin-page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-btn:not(.active) .tab-badge {
|
||||
background: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-input, .filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-input:focus, .filter-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary, .btn-secondary, .btn-sm, .btn-danger, .btn-success, .btn-block {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--color-border); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-sm:hover { background: var(--color-border); }
|
||||
|
||||
.btn-success {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover { opacity: 0.9; }
|
||||
|
||||
.btn-danger {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-block:hover { opacity: 0.9; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Templates Grid */
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.template-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Analytics */
|
||||
.analytics-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.analytics-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.analytics-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover { color: var(--color-text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 20px 0 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Config Section */
|
||||
.config-section {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.version-num {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.version-log {
|
||||
flex: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import './Apps.css';
|
||||
|
||||
interface AIApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
category: string;
|
||||
tags: string | null;
|
||||
icon: string | null;
|
||||
status: string;
|
||||
version: number;
|
||||
is_template: boolean;
|
||||
is_public: boolean;
|
||||
config: any;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
published_at: string | null;
|
||||
analytics_7d?: any;
|
||||
versions?: any[];
|
||||
}
|
||||
|
||||
interface AppStats {
|
||||
total: number;
|
||||
published: number;
|
||||
draft: number;
|
||||
paused: number;
|
||||
archived: number;
|
||||
templates: number;
|
||||
by_type: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
recent_7d: number;
|
||||
total_versions: number;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
category: string;
|
||||
icon: string | null;
|
||||
config: any;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_NAMES: Record<string, string> = {
|
||||
customer_service: '客服助手',
|
||||
data_analysis: '数据分析',
|
||||
coding: '编程辅助',
|
||||
writing: '内容创作',
|
||||
education: '教育培训',
|
||||
marketing: '营销推广',
|
||||
hr: '人力资源',
|
||||
finance: '财务法务',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
const TYPE_NAMES: Record<string, string> = {
|
||||
chatbot: '聊天机器人',
|
||||
assistant: 'AI助手',
|
||||
workflow: '工作流',
|
||||
api_service: 'API服务',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: '#f59e0b',
|
||||
published: '#10b981',
|
||||
paused: '#6b7280',
|
||||
archived: '#9ca3af',
|
||||
};
|
||||
|
||||
const STATUS_NAMES: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
paused: '已暂停',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
export default function AdminApps() {
|
||||
const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps');
|
||||
const [stats, setStats] = useState<AppStats | null>(null);
|
||||
const [apps, setApps] = useState<AIApp[]>([]);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editApp, setEditApp] = useState<AIApp | null>(null);
|
||||
const [detailApp, setDetailApp] = useState<AIApp | null>(null);
|
||||
const [detailAnalytics, setDetailAnalytics] = useState<any>(null);
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAIAppStats();
|
||||
if (res.code === 0) setStats(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
const fetchApps = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', '20');
|
||||
if (search) params.set('search', search);
|
||||
if (filterType) params.set('app_type', filterType);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (filterCategory) params.set('category', filterCategory);
|
||||
const res = await adminAPI.getAIApps(params.toString());
|
||||
if (res.code === 0) {
|
||||
setApps(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
setTotalPages(res.data.total_pages || 1);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, search, filterType, filterStatus, filterCategory]);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAIAppTemplates();
|
||||
if (res.code === 0) setTemplates(res.data.items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
const fetchDetail = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await adminAPI.getAIApp(id);
|
||||
if (res.code === 0) {
|
||||
setDetailApp(res.data);
|
||||
// Fetch analytics
|
||||
try {
|
||||
const aRes = await adminAPI.getAIAppAnalytics(id);
|
||||
if (aRes.code === 0) setDetailAnalytics(aRes.data);
|
||||
} catch (e) { /* analytics may be empty */ }
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'apps') fetchApps();
|
||||
if (activeTab === 'templates') fetchTemplates();
|
||||
}, [activeTab, fetchApps, fetchTemplates]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIApp(data);
|
||||
if (res.code === 0) {
|
||||
setShowCreateModal(false);
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: any) => {
|
||||
if (!editApp) return;
|
||||
try {
|
||||
const res = await adminAPI.updateAIApp(editApp.id, data);
|
||||
if (res.code === 0) {
|
||||
setEditApp(null);
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此应用吗?此操作不可逆。')) return;
|
||||
try {
|
||||
const res = await adminAPI.deleteAIApp(id);
|
||||
if (res.code === 0) {
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleLifecycle = async (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => {
|
||||
try {
|
||||
const res = await adminAPI.aiAppLifecycle(id, action);
|
||||
if (res.code === 0) {
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
if (detailApp?.id === id) fetchDetail(id);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleCreateTemplate = async (data: any) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIAppTemplate(data);
|
||||
if (res.code === 0) {
|
||||
setShowTemplateModal(false);
|
||||
fetchTemplates();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleFromTemplate = async (templateId: string, name: string) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIAppFromTemplate(templateId, { name });
|
||||
if (res.code === 0) {
|
||||
setActiveTab('apps');
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const formatDate = (dt: string | null) => {
|
||||
if (!dt) return '-';
|
||||
try {
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dt; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-apps-page">
|
||||
<div className="admin-page-header">
|
||||
<h1>AI应用管理</h1>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{activeTab === 'apps' && (
|
||||
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>+ 创建应用</button>
|
||||
)}
|
||||
{activeTab === 'templates' && (
|
||||
<button className="btn-primary" onClick={() => setShowTemplateModal(true)}>+ 创建模板</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">应用总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10b981' }}>{stats.published}</div>
|
||||
<div className="stat-label">已发布</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#f59e0b' }}>{stats.draft}</div>
|
||||
<div className="stat-label">草稿</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.templates}</div>
|
||||
<div className="stat-label">应用模板</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#3b82f6' }}>{stats.recent_7d}</div>
|
||||
<div className="stat-label">近7天新增</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs-bar">
|
||||
{([
|
||||
{ key: 'apps' as const, label: '应用列表', count: stats?.total },
|
||||
{ key: 'templates' as const, label: '应用模板', count: stats?.templates },
|
||||
{ key: 'analytics' as const, label: '分析概览', count: undefined as number | undefined },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className="tab-badge">{tab.count}</span>
|
||||
)} </button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Apps Tab */}
|
||||
{activeTab === 'apps' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索应用名称..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="filter-input"
|
||||
/>
|
||||
<select value={filterType} onChange={e => { setFilterType(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STATUS_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>应用名称</th>
|
||||
<th>类型</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>加载中...</td></tr>
|
||||
) : apps.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)' }}>暂无应用</td></tr>
|
||||
) : apps.map(app => (
|
||||
<tr key={app.id} style={{ cursor: 'pointer' }} onClick={() => fetchDetail(app.id)}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{app.name}</div>
|
||||
{app.description && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{app.description}</div>}
|
||||
</td>
|
||||
<td><span className="badge">{TYPE_NAMES[app.type] || app.type}</span></td>
|
||||
<td>{CATEGORY_NAMES[app.category] || app.category}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
|
||||
{STATUS_NAMES[app.status] || app.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>v{app.version}</td>
|
||||
<td style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{formatDate(app.created_at)}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
<button className="btn-sm" onClick={() => setEditApp(app)}>编辑</button>
|
||||
{app.status === 'draft' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>发布</button>}
|
||||
{app.status === 'published' && <button className="btn-sm" onClick={() => handleLifecycle(app.id, 'pause')}>暂停</button>}
|
||||
{app.status === 'paused' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>恢复</button>}
|
||||
<button className="btn-sm" onClick={() => handleLifecycle(app.id, 'duplicate')}>复制</button>
|
||||
<button className="btn-sm btn-danger" onClick={() => handleDelete(app.id)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span>第 {page} / {totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="templates-grid">
|
||||
{templates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
暂无模板,点击右上角"创建模板"添加
|
||||
</div>
|
||||
) : templates.map(tpl => (
|
||||
<div key={tpl.id} className="template-card">
|
||||
<div className="template-icon">{tpl.icon || '📦'}</div>
|
||||
<div className="template-name">{tpl.name}</div>
|
||||
<div className="template-desc">{tpl.description || '暂无描述'}</div>
|
||||
<div className="template-meta">
|
||||
<span className="badge">{TYPE_NAMES[tpl.type] || tpl.type}</span>
|
||||
<span className="badge">{CATEGORY_NAMES[tpl.category] || tpl.category}</span>
|
||||
</div>
|
||||
<button className="btn-primary btn-block" onClick={() => {
|
||||
const name = prompt('请输入应用名称', tpl.name + ' (Instance)');
|
||||
if (name) handleFromTemplate(tpl.id, name);
|
||||
}}>从模板创建</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && stats && (
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '16px' }}>应用类型分布</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '32px' }}>
|
||||
{Object.entries(stats.by_type).map(([type, count]) => (
|
||||
<div key={type} className="analytics-card">
|
||||
<div className="analytics-value">{count}</div>
|
||||
<div className="analytics-label">{TYPE_NAMES[type] || type}</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(stats.by_type).length === 0 && (
|
||||
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '16px' }}>分类分布</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => (
|
||||
<div key={cat} className="analytics-card">
|
||||
<div className="analytics-value">{count}</div>
|
||||
<div className="analytics-label">{CATEGORY_NAMES[cat] || cat}</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(stats.by_category).length === 0 && (
|
||||
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: '32px', marginBottom: '16px' }}>版本统计</h3>
|
||||
<div className="analytics-card" style={{ display: 'inline-block' }}>
|
||||
<div className="analytics-value">{stats.total_versions}</div>
|
||||
<div className="analytics-label">总版本数</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(showCreateModal || editApp) && (
|
||||
<AppFormModal
|
||||
app={editApp}
|
||||
onClose={() => { setShowCreateModal(false); setEditApp(null); }}
|
||||
onSubmit={editApp ? handleUpdate : handleCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Modal */}
|
||||
{showTemplateModal && (
|
||||
<TemplateFormModal
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSubmit={handleCreateTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{detailApp && (
|
||||
<AppDetailDrawer
|
||||
app={detailApp}
|
||||
analytics={detailAnalytics}
|
||||
onClose={() => { setDetailApp(null); setDetailAnalytics(null); }}
|
||||
onAction={handleLifecycle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// App Form Modal
|
||||
// ============================================================
|
||||
|
||||
function AppFormModal({ app, onClose, onSubmit }: {
|
||||
app: AIApp | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(app?.name || '');
|
||||
const [description, setDescription] = useState(app?.description || '');
|
||||
const [type, setType] = useState(app?.type || 'chatbot');
|
||||
const [category, setCategory] = useState(app?.category || 'custom');
|
||||
const [tags, setTags] = useState(app?.tags || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(app?.config?.system_prompt || '');
|
||||
const [temperature, setTemperature] = useState(app?.config?.temperature ?? 0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(app?.config?.max_tokens ?? 2000);
|
||||
const [welcomeMessage, setWelcomeMessage] = useState(app?.config?.welcome_message || '');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) { alert('请输入应用名称'); return; }
|
||||
onSubmit({
|
||||
name, description, type, category, tags,
|
||||
config: {
|
||||
...(app?.config || {}),
|
||||
system_prompt: systemPrompt,
|
||||
temperature: Number(temperature),
|
||||
max_tokens: Number(maxTokens),
|
||||
welcome_message: welcomeMessage,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{app ? '编辑应用' : '创建应用'}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>应用名称 *</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="输入应用名称" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)}>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="应用描述" rows={2} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标签(逗号分隔)</label>
|
||||
<input type="text" value={tags} onChange={e => setTags(e.target.value)} placeholder="如:客服,电商,自动回复" />
|
||||
</div>
|
||||
<div className="form-section-title">AI配置</div>
|
||||
<div className="form-group">
|
||||
<label>系统提示词 (System Prompt)</label>
|
||||
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="定义AI的角色和行为..." rows={4} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>温度 (Temperature): {temperature}</label>
|
||||
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>最大Token数</label>
|
||||
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min="100" max="32000" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>欢迎消息</label>
|
||||
<input type="text" value={welcomeMessage} onChange={e => setWelcomeMessage(e.target.value)} placeholder="用户打开应用时显示的欢迎语" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>取消</button>
|
||||
<button className="btn-primary" onClick={handleSubmit}>{app ? '保存' : '创建'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Template Form Modal
|
||||
// ============================================================
|
||||
|
||||
function TemplateFormModal({ onClose, onSubmit }: {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState('chatbot');
|
||||
const [category, setCategory] = useState('custom');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(2000);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) { alert('请输入模板名称'); return; }
|
||||
onSubmit({
|
||||
name, description, type, category,
|
||||
config: {
|
||||
system_prompt: systemPrompt,
|
||||
temperature: Number(temperature),
|
||||
max_tokens: Number(maxTokens),
|
||||
skill_ids: [],
|
||||
knowledge_base_ids: [],
|
||||
tools: [],
|
||||
welcome_message: ''
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>创建应用模板</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>模板名称 *</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="如:电商客服机器人" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)}>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="模板描述" rows={2} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>系统提示词</label>
|
||||
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="预置的系统提示词..." rows={4} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>温度: {temperature}</label>
|
||||
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>最大Token数</label>
|
||||
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>取消</button>
|
||||
<button className="btn-primary" onClick={handleSubmit}>创建模板</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// App Detail Drawer
|
||||
// ============================================================
|
||||
|
||||
function AppDetailDrawer({ app, analytics, onClose, onAction }: {
|
||||
app: AIApp;
|
||||
analytics: any;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => void;
|
||||
}) {
|
||||
const a7 = app.analytics_7d as any;
|
||||
const summary = analytics?.summary;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} style={{ justifyContent: 'flex-end' }}>
|
||||
<div className="modal-content" style={{ maxWidth: '560px', height: '100vh', borderRadius: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{app.name}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ overflow: 'auto' }}>
|
||||
{/* Status & Meta */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
|
||||
{STATUS_NAMES[app.status] || app.status}
|
||||
</span>
|
||||
<span className="badge">{TYPE_NAMES[app.type] || app.type}</span>
|
||||
<span className="badge">{CATEGORY_NAMES[app.category] || app.category}</span>
|
||||
<span className="badge">v{app.version}</span>
|
||||
</div>
|
||||
|
||||
{app.description && <p style={{ color: 'var(--color-text-secondary)', marginBottom: '16px' }}>{app.description}</p>}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: '24px' }}>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.conversations || 0}</div>
|
||||
<div className="stat-label">7天对话</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.tokens || 0}</div>
|
||||
<div className="stat-label">7天Token</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.errors || 0}</div>
|
||||
<div className="stat-label">7天错误</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<h3 style={{ marginBottom: '12px' }}>应用配置</h3>
|
||||
<div className="config-section">
|
||||
{app.config?.system_prompt && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">系统提示词</span>
|
||||
<div className="config-value">{app.config.system_prompt}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="config-item">
|
||||
<span className="config-label">温度</span>
|
||||
<div className="config-value">{app.config?.temperature ?? 'N/A'}</div>
|
||||
</div>
|
||||
<div className="config-item">
|
||||
<span className="config-label">最大Token</span>
|
||||
<div className="config-value">{app.config?.max_tokens ?? 'N/A'}</div>
|
||||
</div>
|
||||
{app.config?.skill_ids?.length > 0 && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">绑定技能</span>
|
||||
<div className="config-value">{app.config.skill_ids.length} 个技能</div>
|
||||
</div>
|
||||
)}
|
||||
{app.config?.knowledge_base_ids?.length > 0 && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">绑定知识库</span>
|
||||
<div className="config-value">{app.config.knowledge_base_ids.length} 个知识库</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Versions */}
|
||||
{app.versions && app.versions.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>版本历史</h3>
|
||||
<div className="version-list">
|
||||
{app.versions.map((v: any) => (
|
||||
<div key={v.id} className="version-item">
|
||||
<span className="version-num">v{v.version}</span>
|
||||
<span className="version-log">{v.changelog}</span>
|
||||
<span className="version-date">{v.created_at ? new Date(v.created_at).toLocaleDateString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analytics Detail */}
|
||||
{summary && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>分析数据 ({analytics.days}天)</h3>
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)', marginBottom: '16px' }}>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.conversations}</div>
|
||||
<div className="stat-label">总对话数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.messages}</div>
|
||||
<div className="stat-label">总消息数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.tokens_input + summary.tokens_output}</div>
|
||||
<div className="stat-label">总Token</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.active_users}</div>
|
||||
<div className="stat-label">活跃用户</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.avg_response_ms}ms</div>
|
||||
<div className="stat-label">平均响应</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.error_rate}%</div>
|
||||
<div className="stat-label">错误率</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
|
||||
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
|
||||
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
|
||||
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="modal-footer" style={{ justifyContent: 'flex-start' }}>
|
||||
{app.status === 'draft' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>发布</button>}
|
||||
{app.status === 'published' && <button className="btn-secondary" onClick={() => onAction(app.id, 'pause')}>暂停</button>}
|
||||
{app.status === 'paused' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>恢复发布</button>}
|
||||
<button className="btn-secondary" onClick={() => onAction(app.id, 'duplicate')}>复制应用</button>
|
||||
<button className="btn-secondary" onClick={() => onAction(app.id, 'archive')}>归档</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
interface AssetItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
file_type: string | null;
|
||||
size: number;
|
||||
size_formatted: string;
|
||||
description: string | null;
|
||||
url: string | null;
|
||||
cover_url: string | null;
|
||||
status: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface StatsData {
|
||||
total_assets: number;
|
||||
total_size: number;
|
||||
total_size_formatted: string;
|
||||
by_category: Array<{ category: string; label: string; count: number; size: number; size_formatted: string }>;
|
||||
by_file_type: Array<{ file_type: string; count: number; size: number; size_formatted: string }>;
|
||||
top_users: Array<{ user_id: string; username: string; avatar: string | null; asset_count: number; total_size: number; total_size_formatted: string }>;
|
||||
quota_summary: {
|
||||
total_users: number;
|
||||
total_quota: number;
|
||||
total_used: number;
|
||||
total_quota_formatted: string;
|
||||
total_used_formatted: string;
|
||||
usage_percent: number;
|
||||
};
|
||||
trend_7d: Array<{ date: string; new_uploads: number }>;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
category_label: string;
|
||||
file_type: string | null;
|
||||
size: number;
|
||||
size_formatted: string;
|
||||
status: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
file: '文件', novel: '小说', image: '图片', video: '视频', audio: '音频', custom: '自定义', doc: '文档',
|
||||
};
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
file: '#3B82F6', novel: '#8B5CF6', image: '#10B981', video: '#F59E0B', audio: '#EF4444', custom: '#6B7280', doc: '#06B6D4',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Icon
|
||||
// ============================================================
|
||||
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
|
||||
const paths: Record<string, string> = {
|
||||
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
|
||||
activity: 'M22 12h-4l-3 9L9 3l-3 9H2', trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
|
||||
user: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
|
||||
database: 'M12 2C6.48 2 2 3.79 2 6s4.48 4 10 4 10-1.79 10-4-4.48-4-10-4zM2 6v6c0 2.21 4.48 4 10 4s10-1.79 10-4V6M2 12v6c0 2.21 4.48 4 10 4s10-1.79 10-4v-6',
|
||||
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
|
||||
};
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<path d={paths[name] || ''} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main Component
|
||||
// ============================================================
|
||||
const AssetCenter: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'assets' | 'activity'>('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [assetsTotal, setAssetsTotal] = useState(0);
|
||||
const [assetsPage, setAssetsPage] = useState(1);
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
|
||||
// Filters
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterFileType, setFilterFileType] = useState('');
|
||||
const [filterKeyword, setFilterKeyword] = useState('');
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getAssetStats();
|
||||
if (res?.code === 0) setStats(res.data as any);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterCategory) params.set('category', filterCategory);
|
||||
if (filterFileType) params.set('file_type', filterFileType);
|
||||
if (filterKeyword) params.set('keyword', filterKeyword);
|
||||
params.set('page', String(assetsPage));
|
||||
params.set('page_size', '20');
|
||||
const res = await adminAPI.getAdminAssets(params.toString());
|
||||
if (res?.code === 0) {
|
||||
const d = res.data as any;
|
||||
setAssets(d?.items || []);
|
||||
setAssetsTotal(d?.total || 0);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, [filterCategory, filterFileType, filterKeyword, assetsPage]);
|
||||
|
||||
const loadActivities = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getAssetActivity();
|
||||
if (res?.code === 0) setActivities(res.data || []);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview') loadStats();
|
||||
else if (activeTab === 'assets') loadAssets();
|
||||
else if (activeTab === 'activity') loadActivities();
|
||||
}, [activeTab, loadStats, loadAssets, loadActivities]);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确认删除资产「${name}」?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminAsset(id);
|
||||
loadAssets();
|
||||
loadStats();
|
||||
} catch (e) { alert('删除失败'); }
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: '总览', icon: 'chart' },
|
||||
{ key: 'assets', label: '资产管理', icon: 'folder' },
|
||||
{ key: 'activity', label: '活动记录', icon: 'activity' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>资产中心</h1>
|
||||
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>管理用户资产、存储分析、活动追踪</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
|
||||
padding: '10px 20px', border: 'none',
|
||||
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
|
||||
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
|
||||
borderRadius: '8px 8px 0 0', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: '6px', transition: 'all 0.2s',
|
||||
}}>
|
||||
<Icon name={tab.icon} size={16} /> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{!loading && activeTab === 'overview' && stats && <OverviewTab stats={stats} />}
|
||||
|
||||
{/* Assets */}
|
||||
{!loading && activeTab === 'assets' && (
|
||||
<AssetsTab
|
||||
assets={assets} total={assetsTotal} page={assetsPage} onPageChange={setAssetsPage}
|
||||
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
|
||||
filterFileType={filterFileType} setFilterFileType={setFilterFileType}
|
||||
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity */}
|
||||
{!loading && activeTab === 'activity' && <ActivityTab activities={activities} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Overview Tab
|
||||
// ============================================================
|
||||
const OverviewTab: React.FC<{ stats: StatsData }> = ({ stats }) => {
|
||||
const maxTrend = Math.max(...stats.trend_7d.map(t => t.new_uploads), 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stat cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ label: '总资产数', value: stats.total_assets, color: 'var(--color-primary)' },
|
||||
{ label: '总存储量', value: stats.total_size_formatted, color: 'var(--color-success)' },
|
||||
{ label: '配额使用', value: `${stats.quota_summary.usage_percent}%`, sub: `${stats.quota_summary.total_used_formatted} / ${stats.quota_summary.total_quota_formatted}`, color: 'var(--color-warning)' },
|
||||
{ label: '配额用户', value: stats.quota_summary.total_users, color: 'var(--color-danger)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
|
||||
{s.sub && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 7-day trend + Category distribution */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '24px' }}>
|
||||
{/* Trend */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 20px' }}>7日上传趋势</h3>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '140px' }}>
|
||||
{stats.trend_7d.map((t, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '24px', height: `${(t.new_uploads / maxTrend) * 110 + 4}px`,
|
||||
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0', minHeight: '4px',
|
||||
}} title={`${t.new_uploads} uploads`} />
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{t.date.slice(5)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category distribution */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
|
||||
{stats.by_category.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
|
||||
) : (
|
||||
stats.by_category.map(cat => (
|
||||
<div key={cat.category} style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '2px', background: CAT_COLORS[cat.category] || '#6B7280' }} />
|
||||
{cat.label} ({cat.count})
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{cat.size_formatted}</span>
|
||||
</div>
|
||||
<div style={{ height: '6px', background: 'var(--color-bg-tertiary)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', width: `${stats.total_assets > 0 ? (cat.count / stats.total_assets * 100) : 0}%`,
|
||||
background: CAT_COLORS[cat.category] || '#6B7280', borderRadius: '3px', transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top users + File types */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '16px' }}>
|
||||
{/* Top users */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>存储量 Top 10 用户</h3>
|
||||
{stats.top_users.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
|
||||
) : (
|
||||
stats.top_users.map((u, i) => (
|
||||
<div key={u.user_id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>#{i + 1}</span>
|
||||
{u.avatar ? <img src={u.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>{u.username?.charAt(0) || 'U'}</div>}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>{u.username}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{u.asset_count} 个资产</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600 }}>{u.total_size_formatted}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File types */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>文件类型</h3>
|
||||
{stats.by_file_type.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
|
||||
) : (
|
||||
stats.by_file_type.map(ft => (
|
||||
<div key={ft.file_type} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontSize: '13px', fontFamily: 'monospace' }}>{ft.file_type}</span>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600 }}>{ft.count}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{ft.size_formatted}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Assets Tab
|
||||
// ============================================================
|
||||
const AssetsTab: React.FC<{
|
||||
assets: AssetItem[]; total: number; page: number; onPageChange: (p: number) => void;
|
||||
filterCategory: string; setFilterCategory: (v: string) => void;
|
||||
filterFileType: string; setFilterFileType: (v: string) => void;
|
||||
filterKeyword: string; setFilterKeyword: (v: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
}> = ({ assets, total, page, onPageChange, filterCategory, setFilterCategory, filterFileType, setFilterFileType, filterKeyword, setFilterKeyword, onDelete }) => {
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
<input placeholder="搜索资产..." value={filterKeyword} onChange={e => setFilterKeyword(e.target.value)} style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterFileType} onChange={e => setFilterFileType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="">全部类型</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="docx">Word</option>
|
||||
<option value="xlsx">Excel</option>
|
||||
<option value="pptx">PPT</option>
|
||||
<option value="txt">TXT</option>
|
||||
<option value="md">Markdown</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
{['资产名称', '用户', '分类', '类型', '大小', '创建时间', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.map(a => (
|
||||
<tr key={a.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{a.cover_url ? <img src={a.cover_url} alt="" style={{ width: '28px', height: '28px', borderRadius: '4px' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '4px', background: CAT_COLORS[a.category] || '#6B7280', opacity: 0.2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Icon name="folder" size={14} color={CAT_COLORS[a.category] || '#6B7280'} /></div>}
|
||||
<span style={{ fontSize: '13px', fontWeight: 500 }}>{a.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '20px', height: '20px', borderRadius: '50%' }} /> : null}
|
||||
{a.username}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '4px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{CAT_LABELS[a.category] || a.category}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace' }}>{a.file_type || '—'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{a.size_formatted}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{a.created_at?.slice(0, 16) || '—'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<button onClick={() => onDelete(a.id, a.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{assets.length === 0 && <div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无资产</div>}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
|
||||
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Activity Tab
|
||||
// ============================================================
|
||||
const ActivityTab: React.FC<{ activities: ActivityItem[] }> = ({ activities }) => (
|
||||
<div>
|
||||
{activities.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
<Icon name="activity" size={48} color="var(--color-text-tertiary)" />
|
||||
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无活动记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{activities.map(a => (
|
||||
<div key={a.id} style={{ background: 'var(--color-bg-secondary)', borderRadius: '10px', padding: '14px 16px', border: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: '36px', height: '36px', borderRadius: '8px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="folder" size={18} color={CAT_COLORS[a.category] || '#6B7280'} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 500 }}>{a.name}</span>
|
||||
<span style={{ fontSize: '11px', padding: '1px 6px', borderRadius: '3px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{a.category_label}</span>
|
||||
{a.file_type && <span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>{a.file_type}</span>}
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.size_formatted}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '2px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '16px', height: '16px', borderRadius: '50%' }} /> : null}
|
||||
{a.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '12px', color: a.status === 'active' ? 'var(--color-success)' : 'var(--color-danger)' }}>
|
||||
{a.status === 'active' ? '活跃' : '已删除'}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.updated_at?.slice(0, 16) || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AssetCenter;
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface AutomationTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: string;
|
||||
trigger_type_label: string;
|
||||
cron_expression: string;
|
||||
action_type: string;
|
||||
action_type_label: string;
|
||||
action_config: Record<string, any>;
|
||||
event_condition: Record<string, any>;
|
||||
is_enabled: boolean;
|
||||
status: string;
|
||||
max_retries: number;
|
||||
timeout_seconds: number;
|
||||
last_run_at: string;
|
||||
next_run_at: string;
|
||||
total_runs: number;
|
||||
success_runs: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ExecLog {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
status: string;
|
||||
trigger_type: string;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
duration_ms: number;
|
||||
result_summary: string;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AutomationStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
running_tasks: number;
|
||||
by_trigger: Record<string, number>;
|
||||
recent_executions: number;
|
||||
recent_success: number;
|
||||
}
|
||||
|
||||
const TRIGGER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
cron: { label: '定时触发', color: '#6C63FF', bg: '#EDE9FE' },
|
||||
webhook: { label: 'Webhook', color: '#059669', bg: '#D1FAE5' },
|
||||
event: { label: '事件触发', color: '#D97706', bg: '#FEF3C7' },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
idle: { label: '空闲', color: '#6B7280', bg: '#F3F4F6' },
|
||||
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
|
||||
success: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
|
||||
failed: { label: '失败', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const Automation: React.FC = () => {
|
||||
// Data state
|
||||
const [tasks, setTasks] = useState<AutomationTask[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<AutomationStats | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterTrigger, setFilterTrigger] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<AutomationTask | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_type: 'cron',
|
||||
cron_expression: '',
|
||||
event_condition_str: '',
|
||||
action_type: 'ai_pipeline',
|
||||
action_config_str: '{"pipeline_name": "default"}',
|
||||
is_enabled: true,
|
||||
max_retries: 3,
|
||||
timeout_seconds: 300,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Execution history
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [historyTaskId, setHistoryTaskId] = useState('');
|
||||
const [historyTaskName, setHistoryTaskName] = useState('');
|
||||
const [historyLogs, setHistoryLogs] = useState<ExecLog[]>([]);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationStats() as any;
|
||||
setStats(result.data || null);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Load tasks
|
||||
const loadTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (filterTrigger) params.trigger_type = filterTrigger;
|
||||
if (keyword) params.keyword = keyword;
|
||||
const result = await adminAPI.getAdminAutomationTasks(params) as any;
|
||||
const data = result.data || result;
|
||||
setTasks(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast('加载失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadStats(); }, []);
|
||||
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
|
||||
|
||||
// Open create modal
|
||||
const openCreateModal = () => {
|
||||
setEditingTask(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_type: 'cron',
|
||||
cron_expression: '*/5 * * * *',
|
||||
event_condition_str: '',
|
||||
action_type: 'ai_pipeline',
|
||||
action_config_str: '{"pipeline_name": "default"}',
|
||||
is_enabled: true,
|
||||
max_retries: 3,
|
||||
timeout_seconds: 300,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = async (task: AutomationTask) => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationTask(task.id) as any;
|
||||
const detail = result.data;
|
||||
setEditingTask(task);
|
||||
setFormData({
|
||||
name: detail.name || '',
|
||||
description: detail.description || '',
|
||||
trigger_type: detail.trigger_type || 'cron',
|
||||
cron_expression: detail.cron_expression || '',
|
||||
event_condition_str: detail.event_condition ? JSON.stringify(detail.event_condition, null, 2) : '',
|
||||
action_type: detail.action_type || 'ai_pipeline',
|
||||
action_config_str: detail.action_config ? JSON.stringify(detail.action_config, null, 2) : '{}',
|
||||
is_enabled: detail.is_enabled !== false,
|
||||
max_retries: detail.max_retries || 3,
|
||||
timeout_seconds: detail.timeout_seconds || 300,
|
||||
});
|
||||
setModalVisible(true);
|
||||
} catch (e: any) {
|
||||
showToast('获取任务详情失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Submit
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) { showToast('请输入任务名称', 'error'); return; }
|
||||
if (formData.trigger_type === 'cron' && !formData.cron_expression.trim()) {
|
||||
showToast('请输入Cron表达式', 'error'); return;
|
||||
}
|
||||
|
||||
let actionConfig: any = {};
|
||||
if (formData.action_config_str.trim()) {
|
||||
try { actionConfig = JSON.parse(formData.action_config_str); }
|
||||
catch { showToast('操作配置JSON格式不正确', 'error'); return; }
|
||||
}
|
||||
|
||||
let eventCondition: any = undefined;
|
||||
if (formData.event_condition_str.trim()) {
|
||||
try { eventCondition = JSON.parse(formData.event_condition_str); }
|
||||
catch { showToast('事件条件JSON格式不正确', 'error'); return; }
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: any = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
trigger_type: formData.trigger_type,
|
||||
cron_expression: formData.cron_expression.trim(),
|
||||
action_type: formData.action_type,
|
||||
action_config: actionConfig,
|
||||
is_enabled: formData.is_enabled,
|
||||
max_retries: formData.max_retries,
|
||||
timeout_seconds: formData.timeout_seconds,
|
||||
};
|
||||
if (eventCondition) data.event_condition = eventCondition;
|
||||
|
||||
if (editingTask) {
|
||||
await adminAPI.updateAdminAutomationTask(editingTask.id, data);
|
||||
showToast('更新成功');
|
||||
} else {
|
||||
await adminAPI.createAdminAutomationTask(data);
|
||||
showToast('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete
|
||||
const handleDelete = async (task: AutomationTask) => {
|
||||
if (!window.confirm(`确定要删除任务 "${task.name}" 吗?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminAutomationTask(task.id);
|
||||
showToast('删除成功');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('删除失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle
|
||||
const handleToggle = async (task: AutomationTask) => {
|
||||
try {
|
||||
await adminAPI.toggleAdminAutomationTask(task.id);
|
||||
showToast(task.is_enabled ? '已禁用' : '已启用');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Execute now
|
||||
const handleExecuteNow = async (task: AutomationTask) => {
|
||||
setExecutingId(task.id);
|
||||
try {
|
||||
const result = await adminAPI.executeAdminAutomationTask(task.id) as any;
|
||||
showToast(result.message || '执行完成');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('执行失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setExecutingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Show history
|
||||
const showHistory = async (task: AutomationTask, pageNum: number = 1) => {
|
||||
setHistoryTaskId(task.id);
|
||||
setHistoryTaskName(task.name);
|
||||
setHistoryPage(pageNum);
|
||||
setHistoryVisible(true);
|
||||
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationTaskHistory(task.id, { page: pageNum, page_size: 20 }) as any;
|
||||
const data = result.data || result;
|
||||
setHistoryLogs(data.items || []);
|
||||
setHistoryTotal(data.total || 0);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (ts: string) => {
|
||||
if (!ts) return '-';
|
||||
try { return new Date(ts).toLocaleString('zh-CN'); }
|
||||
catch { return ts; }
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (ms: number) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 };
|
||||
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' };
|
||||
const btnStyle = (bg: string, disabled = false): React.CSSProperties => ({
|
||||
padding: '8px 20px', borderRadius: '6px', border: 'none', background: bg,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff',
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
|
||||
<h2>自动化任务调度</h2>
|
||||
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_tasks}</div>
|
||||
<div className="stat-label">总任务数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_tasks}</div>
|
||||
<div className="stat-label">已启用</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#3B82F6' }}>{stats.running_tasks}</div>
|
||||
<div className="stat-label">运行中</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.recent_executions}</div>
|
||||
<div className="stat-label">24小时执行</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.recent_success}</div>
|
||||
<div className="stat-label">24小时成功</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||
<select value={filterTrigger} onChange={(e) => { setFilterTrigger(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(TRIGGER_CONFIG).map(([k, v]) => (<option key={k} value={k}>{v.label}</option>))}
|
||||
</select>
|
||||
<input type="text" placeholder="搜索任务名称..."
|
||||
value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
|
||||
<th style={thStyle}>任务名称</th>
|
||||
<th style={thStyle}>触发方式</th>
|
||||
<th style={thStyle}>操作类型</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>上次执行</th>
|
||||
<th style={thStyle}>下次执行</th>
|
||||
<th style={thStyle}>成功率</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right', width: '320px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} style={emptyStyle}>加载中...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td colSpan={8} style={emptyStyle}>暂无自动化任务</td></tr>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ fontWeight: 500 }}>{task.name}</div>
|
||||
{task.description && <div style={{ fontSize: '12px', color: '#9CA3AF', marginTop: '2px' }}>{task.description}</div>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badgeStyle(TRIGGER_CONFIG[task.trigger_type] || TRIGGER_CONFIG.cron)}>
|
||||
{(TRIGGER_CONFIG[task.trigger_type] || {}).label || task.trigger_type}
|
||||
</span>
|
||||
{task.cron_expression && <div style={{ fontSize: '12px', fontFamily: 'monospace', marginTop: '2px', color: '#6B7280' }}>{task.cron_expression}</div>}
|
||||
</td>
|
||||
<td style={tdStyle}>{task.action_type_label}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<span style={badgeStyle(STATUS_CONFIG[task.status] || STATUS_CONFIG.idle)}>
|
||||
{(STATUS_CONFIG[task.status] || {}).label || task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>{formatTime(task.last_run_at)}</td>
|
||||
<td style={tdStyle}>{formatTime(task.next_run_at)}</td>
|
||||
<td style={tdStyle}>
|
||||
{task.total_runs > 0
|
||||
? <span style={{ color: task.success_runs / task.total_runs >= 0.9 ? '#10B981' : '#D97706' }}>
|
||||
{Math.round(task.success_runs / task.total_runs * 100)}%
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF' }}> ({task.success_runs}/{task.total_runs})</span>
|
||||
</span>
|
||||
: <span style={{ color: '#9CA3AF' }}>-</span>}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
|
||||
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
|
||||
{executingId === task.id ? '执行中...' : '执行'}
|
||||
</button>
|
||||
<button onClick={() => showHistory(task)}
|
||||
style={actionBtnStyle('#374151')}>历史</button>
|
||||
<button onClick={() => handleToggle(task)}
|
||||
style={actionBtnStyle(task.is_enabled ? '#EF4444' : '#10B981')}>
|
||||
{task.is_enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button onClick={() => openEditModal(task)}
|
||||
style={actionBtnStyle('#374151')}>编辑</button>
|
||||
<button onClick={() => handleDelete(task)}
|
||||
style={actionBtnStyle('#EF4444', false, true)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{modalVisible && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div><label style={labelStyle}>任务名称 *</label>
|
||||
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
|
||||
|
||||
<div><label style={labelStyle}>描述</label>
|
||||
<input type="text" value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} style={inputStyle} placeholder="可选描述" /></div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div><label style={labelStyle}>触发方式</label>
|
||||
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
|
||||
<option value="cron">定时触发 (Cron)</option>
|
||||
<option value="webhook">Webhook回调</option>
|
||||
<option value="event">事件触发</option>
|
||||
</select></div>
|
||||
<div><label style={labelStyle}>操作类型</label>
|
||||
<select value={formData.action_type} onChange={e => setFormData({ ...formData, action_type: e.target.value })} style={inputStyle}>
|
||||
<option value="ai_pipeline">AI流水线</option>
|
||||
<option value="webhook">Webhook回调</option>
|
||||
<option value="notification">发送通知</option>
|
||||
<option value="skill_execute">技能执行</option>
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
{formData.trigger_type === 'cron' && (
|
||||
<div><label style={labelStyle}>Cron 表达式 *</label>
|
||||
<input type="text" value={formData.cron_expression} onChange={e => setFormData({ ...formData, cron_expression: e.target.value })}
|
||||
style={{ ...inputStyle, fontFamily: 'monospace' }} placeholder="*/5 * * * * (每5分钟)" />
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '4px' }}>
|
||||
格式: 分 时 日 月 周 | 示例: 0 9 * * * (每天9点)
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
{formData.trigger_type !== 'cron' && (
|
||||
<div><label style={labelStyle}>事件条件 (JSON)</label>
|
||||
<textarea value={formData.event_condition_str} onChange={e => setFormData({ ...formData, event_condition_str: e.target.value })}
|
||||
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
|
||||
placeholder={formData.trigger_type === 'webhook' ? '{"method": "POST", "secret": "xxx"}' : '{"event_type": "user.created"}'} /></div>
|
||||
)}
|
||||
|
||||
<div><label style={labelStyle}>操作配置 (JSON)</label>
|
||||
<textarea value={formData.action_config_str} onChange={e => setFormData({ ...formData, action_config_str: e.target.value })}
|
||||
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
|
||||
placeholder='{"pipeline_name": "default"}' /></div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
||||
<div><label style={labelStyle}>重试次数</label>
|
||||
<input type="number" value={formData.max_retries} onChange={e => setFormData({ ...formData, max_retries: parseInt(e.target.value) || 0 })}
|
||||
min={0} max={10} style={inputStyle} /></div>
|
||||
<div><label style={labelStyle}>超时(秒)</label>
|
||||
<input type="number" value={formData.timeout_seconds} onChange={e => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
|
||||
min={10} max={3600} style={inputStyle} /></div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: '8px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={formData.is_enabled} onChange={e => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用</label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151' }}>取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} style={btnStyle('#6C63FF', submitting)}>
|
||||
{submitting ? '保存中...' : (editingTask ? '保存修改' : '创建任务')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Modal */}
|
||||
{historyVisible && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '700px', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px' }}>执行历史: {historyTaskName}</h3>
|
||||
<button onClick={() => setHistoryVisible(false)} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#6B7280' }}>✕</button>
|
||||
</div>
|
||||
|
||||
{historyLogs.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB' }}>
|
||||
<th style={thStyle}>时间</th>
|
||||
<th style={thStyle}>触发方式</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>耗时</th>
|
||||
<th style={thStyle}>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyLogs.map(log => (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={tdStyle}>{formatTime(log.started_at)}</td>
|
||||
<td style={tdStyle}>{log.trigger_type}</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badgeStyle(STATUS_CONFIG[log.status] || STATUS_CONFIG.idle)}>
|
||||
{(STATUS_CONFIG[log.status] || {}).label || log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>{formatDuration(log.duration_ms)}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: '250px' }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{log.result_summary || log.error_message || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* History pagination */}
|
||||
{historyTotal > 20 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button disabled={historyPage <= 1} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage - 1)}
|
||||
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '4px 8px', color: '#6B7280', fontSize: '13px' }}>{historyPage} / {Math.ceil(historyTotal / 20)}</span>
|
||||
<button disabled={historyPage >= Math.ceil(historyTotal / 20)} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage + 1)}
|
||||
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 2000, padding: '12px 24px', borderRadius: '8px', color: '#fff',
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px' }}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Inline styles
|
||||
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 };
|
||||
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px' };
|
||||
const emptyStyle: React.CSSProperties = { padding: '40px', textAlign: 'center', color: '#9CA3AF' };
|
||||
const badgeStyle = (cfg: { color: string; bg: string }) => ({
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: cfg.color, background: cfg.bg,
|
||||
} as React.CSSProperties);
|
||||
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
|
||||
padding: '5px 12px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
|
||||
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
|
||||
});
|
||||
|
||||
export default Automation;
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; }
|
||||
interface Package { id: string; name: string; description: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; }
|
||||
interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; }
|
||||
interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; }
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview');
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getBillingRules();
|
||||
const d = (res as any)?.data || res;
|
||||
setRules(d?.items || []);
|
||||
} catch (e) { console.error('fetchRules:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchPackages = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getBillingPackages();
|
||||
const d = (res as any)?.data || res;
|
||||
setPackages(Array.isArray(d) ? d : d?.items || []);
|
||||
} catch (e) { console.error('fetchPackages:', e); }
|
||||
}, []);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getBillingTransactions(page);
|
||||
const d = (res as any)?.data || res;
|
||||
setTransactions(d?.items || []);
|
||||
setTotal(d?.total || 0);
|
||||
} catch (e) { console.error('fetchTransactions:', e); }
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getBillingStats();
|
||||
setStats((res as any)?.data || res);
|
||||
} catch (e) { console.error('fetchStats:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||
useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]);
|
||||
useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]);
|
||||
useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]);
|
||||
|
||||
const typeColors: Record<string, string> = { per_token: '#6C63FF', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
|
||||
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="responsive-grid-4" style={{ gap: '16px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' },
|
||||
{ label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' },
|
||||
{ label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' },
|
||||
{ label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setTab(k)}
|
||||
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280', fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
{tab === 'overview' && stats && (
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px', color: 'var(--color-text-primary)' }}>近7天收入趋势</h3>
|
||||
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
|
||||
{stats.daily_revenue_7d.map((d, i) => {
|
||||
const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1);
|
||||
const h = (d.revenue / max) * 160;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '11px', color: '#6B7280', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
|
||||
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6C63FF, #818cf8)', borderRadius: '4px 4px 0 0' }} />
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '6px' }}>{d.date.substring(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>暂无收入数据</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules */}
|
||||
{tab === 'rules' && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<thead><tr style={{ background: '#F8F9FC' }}>
|
||||
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rules.map(r => (
|
||||
<tr key={r.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.model_name || '通用'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.free_quota?.toLocaleString() || 0}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: r.status === 'active' ? '#10b981' : '#9CA3AF', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{tab === 'packages' && (
|
||||
<div className="responsive-grid-4" style={{ gap: '16px' }}>
|
||||
{packages.map(p => (
|
||||
<div key={p.id} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6C63FF' : '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
{p.is_popular === 1 && (
|
||||
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6C63FF', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
|
||||
<p style={{ fontSize: '13px', color: '#6B7280', marginBottom: '16px' }}>{p.description || ''}</p>
|
||||
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6C63FF' }}>¥{p.price}</div>
|
||||
{p.original_price && <div style={{ fontSize: '14px', color: '#9CA3AF', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
|
||||
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid rgba(0,0,0,0.08)', fontSize: '13px', color: '#6B7280' }}>
|
||||
<div>主额度: <span style={{ color: '#1A1F36', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
|
||||
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '12px' }}>
|
||||
<span style={{ color: p.status === 'active' ? '#10b981' : '#9CA3AF' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
{tab === 'transactions' && (
|
||||
<>
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<thead><tr style={{ background: '#F8F9FC' }}>
|
||||
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{transactions.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 16)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.tokens?.toLocaleString() || '-'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.description || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无交易记录</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{total > 20 && (
|
||||
<div className="pagination-bar" style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
|
||||
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
@@ -0,0 +1,747 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface ExecStats {
|
||||
total: number;
|
||||
today: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
timeout: number;
|
||||
avg_execution_time_ms: number;
|
||||
by_language: Record<string, number>;
|
||||
}
|
||||
|
||||
interface RuntimeInfo {
|
||||
python?: string;
|
||||
nodejs?: string;
|
||||
max_code_length?: number;
|
||||
max_timeout?: number;
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
language: string;
|
||||
code_preview: string;
|
||||
status: string;
|
||||
exit_code: number | null;
|
||||
execution_time_ms: number | null;
|
||||
error_msg: string | null;
|
||||
truncated: boolean;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
task_id: string;
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number;
|
||||
execution_time_ms: number;
|
||||
error: string | null;
|
||||
truncated: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const LANG_CONFIG: Record<string, { label: string; color: string; bg: string; placeholder: string }> = {
|
||||
python: { label: 'Python', color: '#306998', bg: '#E8F4FD', placeholder: '# Python 代码示例\nprint("Hello, sandbox!")\n\nfor i in range(5):\n print(f"Line {i}")' },
|
||||
javascript: { label: 'JavaScript', color: '#F7DF1E', bg: '#FFFDE8', placeholder: '// JavaScript 代码示例\nconsole.log("Hello, sandbox!");\n\nfor (let i = 0; i < 5; i++) {\n console.log(`Line ${i}`);\n}' },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
completed: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
|
||||
error: { label: '错误', color: '#EF4444', bg: '#FEE2E2' },
|
||||
failed: { label: '失败', color: '#F59E0B', bg: '#FEF3C7' },
|
||||
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const CodeExec: React.FC = () => {
|
||||
// Stats
|
||||
const [stats, setStats] = useState<ExecStats | null>(null);
|
||||
const [runtimes, setRuntimes] = useState<RuntimeInfo | null>(null);
|
||||
|
||||
// Editor
|
||||
const [language, setLanguage] = useState('python');
|
||||
const [code, setCode] = useState(LANG_CONFIG.python.placeholder);
|
||||
const [timeout, setTimeout_] = useState(30);
|
||||
|
||||
// Execution
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [result, setResult] = useState<ExecResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout');
|
||||
|
||||
// Task History
|
||||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterLang, setFilterLang] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
|
||||
// Task Detail Modal
|
||||
const [detailTask, setDetailTask] = useState<TaskItem | null>(null);
|
||||
const [detailResult, setDetailResult] = useState<ExecResult | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load stats & runtimes on mount
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadRuntimes();
|
||||
}, []);
|
||||
|
||||
// Load tasks
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [page, filterLang, filterStatus]);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getCodeExecStats() as any;
|
||||
setStats(res.data || null);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const loadRuntimes = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getCodeExecRuntimes() as any;
|
||||
setRuntimes(res.data || null);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const loadTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (filterLang) params.language = filterLang;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
const res = await adminAPI.getCodeExecTasks(params) as any;
|
||||
const data = res.data || res;
|
||||
setTasks(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast('加载任务列表失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute code
|
||||
const handleExecute = async () => {
|
||||
if (!code.trim()) {
|
||||
showToast('请输入代码', 'error');
|
||||
return;
|
||||
}
|
||||
setExecuting(true);
|
||||
setResult(null);
|
||||
setActiveTab('stdout');
|
||||
try {
|
||||
const res = await adminAPI.executeCode({ language, code: code.trim(), timeout }) as any;
|
||||
setResult(res.data || res);
|
||||
} catch (e: any) {
|
||||
setResult({
|
||||
task_id: '',
|
||||
success: false,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exit_code: -1,
|
||||
execution_time_ms: 0,
|
||||
error: e.message || '执行失败',
|
||||
truncated: false,
|
||||
created_at: '',
|
||||
});
|
||||
showToast('执行失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
loadStats();
|
||||
loadTasks();
|
||||
}
|
||||
};
|
||||
|
||||
// View task detail
|
||||
const viewTaskDetail = async (task: TaskItem) => {
|
||||
setDetailTask(task);
|
||||
setDetailResult(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getCodeExecTask(task.id) as any;
|
||||
setDetailResult(res.data || null);
|
||||
} catch (e: any) {
|
||||
showToast('获取任务详情失败', 'error');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete task
|
||||
const handleDeleteTask = async (task: TaskItem) => {
|
||||
if (!window.confirm(`确定要删除任务 ${task.id} 吗?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteCodeExecTask(task.id);
|
||||
showToast('删除成功');
|
||||
if (detailTask?.id === task.id) {
|
||||
setDetailTask(null);
|
||||
setDetailResult(null);
|
||||
}
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('删除失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd+Enter to execute
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleExecute();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-header">
|
||||
<h2>代码执行沙箱</h2>
|
||||
<span style={{ fontSize: '13px', color: '#6B7280', marginLeft: '12px' }}>
|
||||
安全隔离的 Python / JavaScript 代码执行环境
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.today}</div>
|
||||
<div className="stat-label">今日执行</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.success}</div>
|
||||
<div className="stat-label">成功</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#EF4444' }}>{stats.failed + stats.timeout}</div>
|
||||
<div className="stat-label">失败/超时</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.avg_execution_time_ms.toFixed(0)}ms</div>
|
||||
<div className="stat-label">平均耗时</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">总执行次数</div>
|
||||
</div>
|
||||
{/* Language breakdown */}
|
||||
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginTop: '4px' }}>
|
||||
{Object.entries(stats.by_language || {}).map(([lang, count]) => (
|
||||
<span key={lang} style={{
|
||||
padding: '2px 12px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (LANG_CONFIG[lang] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[lang] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[lang] || {}).label || lang}: {count}
|
||||
</span>
|
||||
))}
|
||||
{Object.keys(stats.by_language || {}).length === 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>暂无数据</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-label">语言分布</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main grid: Editor + Output */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '24px' }}>
|
||||
{/* Left: Code Editor */}
|
||||
<div style={{
|
||||
background: '#F8F9FC', borderRadius: '12px', border: '1px solid #313244',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* Editor header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 16px', borderBottom: '1px solid #313244',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FF5F57' }} />
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FFBD2E' }} />
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#27CA40' }} />
|
||||
</div>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => {
|
||||
setLanguage(e.target.value);
|
||||
setCode(LANG_CONFIG[e.target.value]?.placeholder || '');
|
||||
setResult(null);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: '6px', border: '1px solid #45475A',
|
||||
background: '#313244', color: '#CDD6F4', fontSize: '13px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="python">Python</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
</select>
|
||||
<span style={{ fontSize: '12px', color: '#6C7086' }}>
|
||||
{runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#6C7086' }}>超时:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={120}
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout_(parseInt(e.target.value))}
|
||||
style={{ width: '80px', accentColor: '#6C63FF' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: '#CDD6F4', minWidth: '28px' }}>{timeout}s</span>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: 'none',
|
||||
background: executing ? '#45475A' : '#6C63FF',
|
||||
color: '#1A1F36', cursor: executing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{executing ? '执行中...' : '▶ 运行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code textarea */}
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={LANG_CONFIG[language]?.placeholder || '输入代码...'}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1, minHeight: '320px', padding: '16px', border: 'none',
|
||||
background: '#F8F9FC', color: '#CDD6F4', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
|
||||
fontSize: '13px', lineHeight: 1.6, resize: 'vertical',
|
||||
outline: 'none', tabSize: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div style={{
|
||||
padding: '6px 16px', borderTop: '1px solid #313244',
|
||||
fontSize: '11px', color: '#585B70',
|
||||
}}>
|
||||
Ctrl+Enter 运行 · {language === 'python' ? 'Python 3.11' : 'Node.js'} · 最大 {runtimes?.max_code_length ? (runtimes.max_code_length / 1024).toFixed(0) + 'KB' : '50KB'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Output */}
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* Output header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 16px', borderBottom: '1px solid #E5E7EB',
|
||||
background: '#F9FAFB',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#374151' }}>执行结果</span>
|
||||
{result && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '4px', fontSize: '11px',
|
||||
color: result.success ? '#fff' : '#fff',
|
||||
background: result.success ? '#10B981' : (result.error ? '#EF4444' : '#F59E0B'),
|
||||
}}>
|
||||
{result.success ? '成功' : (result.error?.includes('timeout') ? '超时' : '失败')}
|
||||
</span>
|
||||
)}
|
||||
{result && !result.success && result.exit_code !== 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280' }}>
|
||||
exit_code={result.exit_code}
|
||||
</span>
|
||||
)}
|
||||
{result && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280' }}>
|
||||
{result.execution_time_ms.toFixed(0)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('stdout')}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: 'none',
|
||||
background: activeTab === 'stdout' ? '#6C63FF' : '#F3F4F6',
|
||||
color: activeTab === 'stdout' ? '#fff' : '#6B7280',
|
||||
cursor: 'pointer', fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
stdout
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stderr')}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: 'none',
|
||||
background: activeTab === 'stderr' ? '#EF4444' : '#F3F4F6',
|
||||
color: activeTab === 'stderr' ? '#fff' : '#6B7280',
|
||||
cursor: 'pointer', fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
stderr{result.stderr ? ` (${result.stderr.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output content */}
|
||||
<div ref={outputRef} style={{
|
||||
flex: 1, minHeight: '320px', padding: '16px', overflow: 'auto',
|
||||
background: '#F8F9FC',
|
||||
}}>
|
||||
{executing ? (
|
||||
<div style={{ color: '#6C7086', fontSize: '13px', fontFamily: 'monospace' }}>
|
||||
<span style={{ animation: 'blink 1s infinite' }}>⏳</span> 正在执行...
|
||||
</div>
|
||||
) : !result ? (
|
||||
<div style={{ color: '#585B70', fontSize: '13px', fontFamily: 'monospace', textAlign: 'center', paddingTop: '60px' }}>
|
||||
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{'</>'}</div>
|
||||
<div>点击「运行」按钮执行代码</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '11px' }}>或按 Ctrl+Enter 快捷运行</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.error && (
|
||||
<div style={{
|
||||
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
|
||||
background: '#FEE2E2', color: '#DC2626', fontSize: '12px',
|
||||
fontFamily: "'Fira Code', monospace",
|
||||
}}>
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.truncated && (
|
||||
<div style={{
|
||||
padding: '4px 12px', marginBottom: '8px', borderRadius: '4px',
|
||||
background: '#FEF3C7', color: '#92400E', fontSize: '11px', fontFamily: 'monospace',
|
||||
}}>
|
||||
输出已截断 (超过 500KB)
|
||||
</div>
|
||||
)}
|
||||
<pre style={{
|
||||
margin: 0, padding: 0, background: 'transparent', color: '#CDD6F4',
|
||||
fontSize: '13px', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
|
||||
lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
minHeight: activeTab === 'stdout' && !result.stdout.trim() ? 'auto' : undefined,
|
||||
}}>
|
||||
{activeTab === 'stdout'
|
||||
? (result.stdout.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
|
||||
: (result.stderr.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
|
||||
}
|
||||
</pre>
|
||||
{result.task_id && (
|
||||
<div style={{ marginTop: '12px', padding: '6px 0', borderTop: '1px solid #313244', fontSize: '11px', color: '#585B70', fontFamily: 'monospace' }}>
|
||||
Task ID: {result.task_id}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task History */}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', color: '#374151' }}>执行历史</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<select
|
||||
value={filterLang}
|
||||
onChange={(e) => { setFilterLang(e.target.value); setPage(1); }}
|
||||
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
|
||||
>
|
||||
<option value="">全部语言</option>
|
||||
<option value="python">Python</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||||
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="completed">成功</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadTasks}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#374151',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '100px' }}>任务ID</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>语言</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>代码预览</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>状态</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>耗时</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '160px' }}>创建时间</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '120px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</td></tr>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={{ padding: '10px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6C63FF' }}>
|
||||
{task.id}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
|
||||
color: (LANG_CONFIG[task.language] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[task.language] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[task.language] || {}).label || task.language}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.code_preview || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
|
||||
color: (STATUS_CONFIG[task.status] || {}).color || '#666',
|
||||
background: (STATUS_CONFIG[task.status] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(STATUS_CONFIG[task.status] || {}).label || task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
{task.execution_time_ms != null ? `${task.execution_time_ms}ms` : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280' }}>
|
||||
{task.created_at || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => viewTaskDetail(task)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: 'pointer', fontSize: '11px', color: '#374151',
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: '1px solid #FEE2E2',
|
||||
background: '#FEF2F2', cursor: 'pointer', fontSize: '11px', color: '#EF4444',
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: page <= 1 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
|
||||
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= Math.ceil(total / PAGE_SIZE)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
|
||||
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{detailTask && (
|
||||
<div className="modal-overlay" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
}} onClick={() => { setDetailTask(null); setDetailResult(null); }}>
|
||||
<div className="modal-content" style={{
|
||||
background: '#fff', borderRadius: '12px', padding: '24px',
|
||||
width: '700px', maxHeight: '85vh', overflow: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px' }}>任务详情 - {detailTask.id}</h3>
|
||||
<button
|
||||
onClick={() => { setDetailTask(null); setDetailResult(null); }}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', border: 'none', background: '#F3F4F6', cursor: 'pointer', fontSize: '16px', color: '#6B7280' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task info bar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (LANG_CONFIG[detailTask.language] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[detailTask.language] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[detailTask.language] || {}).label || detailTask.language}
|
||||
</span>
|
||||
<span style={{
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (STATUS_CONFIG[detailTask.status] || {}).color || '#666',
|
||||
background: (STATUS_CONFIG[detailTask.status] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(STATUS_CONFIG[detailTask.status] || {}).label || detailTask.status}
|
||||
</span>
|
||||
{detailTask.execution_time_ms != null && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
耗时: {detailTask.execution_time_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{detailTask.exit_code != null && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
exit_code: {detailTask.exit_code}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>
|
||||
{detailTask.created_at}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>代码预览</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '150px', overflow: 'auto',
|
||||
}}>
|
||||
{detailTask.code_preview || '(无)'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Full output */}
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div>
|
||||
) : detailResult ? (
|
||||
<>
|
||||
{detailResult.error && (
|
||||
<div style={{
|
||||
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
|
||||
background: '#FEE2E2', color: '#DC2626', fontSize: '12px', fontFamily: 'monospace',
|
||||
}}>
|
||||
{detailResult.error}
|
||||
</div>
|
||||
)}
|
||||
{detailResult.stdout && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>stdout</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}>
|
||||
{detailResult.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{detailResult.stderr && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#EF4444', fontWeight: 500 }}>stderr</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#F8F9FC', color: '#F38BA8', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}>
|
||||
{detailResult.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!detailResult.stdout.trim() && !detailResult.stderr.trim() && !detailResult.error && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>(无输出)</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>无法获取详细信息</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
|
||||
padding: '12px 24px', borderRadius: '8px', color: '#1A1F36',
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeExec;
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface DashboardStats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_models: number;
|
||||
enabled_models: number;
|
||||
total_conversations: number;
|
||||
active_conversations: number;
|
||||
total_knowledge_bases: number;
|
||||
total_documents: number;
|
||||
today_orders: number;
|
||||
today_tokens: number;
|
||||
week_tokens: number;
|
||||
total_tokens: number;
|
||||
today_conversations: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
interface DailyStat {
|
||||
date: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
const quickEntries = [
|
||||
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
|
||||
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
||||
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
|
||||
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
|
||||
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
|
||||
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
|
||||
];
|
||||
|
||||
// SVG Bar Chart Component
|
||||
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
|
||||
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
|
||||
|
||||
const maxVal = Math.max(...data.map(d => d.tokens), 1);
|
||||
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
|
||||
const barAreaW = w - padLeft - padRight;
|
||||
const barAreaH = h - padTop - padBottom;
|
||||
const barGap = 2;
|
||||
const barW = Math.max(2, (barAreaW / data.length) - barGap);
|
||||
|
||||
// Y axis ticks
|
||||
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
|
||||
|
||||
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
|
||||
const formatDate = (d: string) => {
|
||||
const parts = d.split('-');
|
||||
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
|
||||
{/* Grid lines */}
|
||||
{yTicks.map((tick, i) => {
|
||||
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
|
||||
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const barH = (d.tokens / maxVal) * barAreaH;
|
||||
const x = padLeft + i * (barAreaW / data.length);
|
||||
const y = padTop + barAreaH - barH;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
|
||||
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
|
||||
</rect>
|
||||
{/* X label - every 7th bar */}
|
||||
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
|
||||
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Mini Donut for stat cards
|
||||
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
|
||||
const pct = max > 0 ? Math.min(value / max, 1) : 0;
|
||||
const r = 16, c = 20, circumference = 2 * Math.PI * r;
|
||||
const dash = circumference * pct;
|
||||
return (
|
||||
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
|
||||
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
|
||||
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
|
||||
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
|
||||
{Math.round(pct * 100)}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
|
||||
total_conversations: 0, active_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
|
||||
today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
|
||||
today_conversations: 0, server_time: '',
|
||||
});
|
||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
|
||||
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
|
||||
adminAPI.getStats() as any,
|
||||
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
|
||||
adminAPI.getRecentLogs() as any,
|
||||
adminAPI.getRecentAudit() as any,
|
||||
]);
|
||||
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
|
||||
const statsData = statsRes?.data || statsRes;
|
||||
if (statsData && typeof statsData === 'object') setStats(statsData);
|
||||
|
||||
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
|
||||
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
|
||||
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
|
||||
|
||||
// Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array
|
||||
const logsData = Array.isArray(logsRes) ? logsRes : (Array.isArray(logsRes?.data) ? logsRes.data : []);
|
||||
setOperationLogs(logsData);
|
||||
|
||||
// Recent audit: same as logs
|
||||
const auditData = Array.isArray(auditRes) ? auditRes : (Array.isArray(auditRes?.data) ? auditRes.data : []);
|
||||
setAuditLogs(auditData);
|
||||
} catch (err) {
|
||||
console.error('Dashboard数据加载失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
if (!d) return '-';
|
||||
const dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
|
||||
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatTokens = (n: number): string => {
|
||||
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
|
||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||
return n.toLocaleString('en-US');
|
||||
};
|
||||
|
||||
const statCardDefs = [
|
||||
{ label: '总用户', value: stats.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
|
||||
icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
|
||||
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
|
||||
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||
{ label: '对话数', value: stats.total_conversations, sub: `活跃 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
|
||||
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
|
||||
{ label: '知识库', value: stats.total_knowledge_bases, sub: `文档 ${stats.total_documents}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
|
||||
icon: <SVGIcon size={22}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon> },
|
||||
{ label: '今日对话', value: stats.today_conversations, sub: `订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
|
||||
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
||||
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
|
||||
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
|
||||
<div className="audit-spinner" />
|
||||
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<h1 className="admin-page-title">平台驾驶舱</h1>
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{statCardDefs.map((s, i) => (
|
||||
<div key={i} className="admin-stat-card" style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
||||
padding: 'var(--space-lg) var(--space-xl)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 14,
|
||||
background: s.bg, color: s.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
|
||||
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: s.color, marginTop: 2,
|
||||
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
|
||||
display: 'inline-block',
|
||||
}}>{s.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
||||
{/* Token Trend Chart */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">
|
||||
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||
{' '}Token消耗趋势(近30天)
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
||||
<BarChart data={dailyStats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">
|
||||
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
||||
{' '}平台概览
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ padding: 'var(--space-lg)' }}>
|
||||
{[
|
||||
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
|
||||
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
|
||||
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
|
||||
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
|
||||
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
|
||||
服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-md)' }}>
|
||||
{quickEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
onClick={() => navigate(entry.path)}
|
||||
style={{
|
||||
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
|
||||
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6C63FF';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Logs */}
|
||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">最近操作日志</h3>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>详情</th>
|
||||
<th>IP</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operationLogs.length > 0 ? operationLogs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontWeight: 500 }}>{log.username}</td>
|
||||
<td><span className="admin-badge badge-info">{log.action}</span></td>
|
||||
<td>{log.target}</td>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Audit Logs */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">最近审计日志</h3>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作人</th>
|
||||
<th>操作类型</th>
|
||||
<th>目标类型</th>
|
||||
<th>详情</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLogs.length > 0 ? auditLogs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
|
||||
<td><span className="admin-badge badge-warning">{log.action}</span></td>
|
||||
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
|
||||
<td>{log.detail}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,155 @@
|
||||
.admin-developer { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
.dev-header { margin-bottom: 24px; }
|
||||
.dev-header h2 { font-size: 22px; margin: 0 0 6px 0; }
|
||||
.dev-header p { color: var(--text-secondary, #666); font-size: 14px; margin: 0; }
|
||||
|
||||
/* Stats Grid */
|
||||
.dev-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.dev-stat-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 18px; border-radius: 10px;
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border-color, #e8e8e8);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.stat-icon { font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 10px; flex-shrink: 0; }
|
||||
.icon-key { background: rgba(24,144,255,0.1); }
|
||||
.icon-client { background: rgba(82,196,26,0.1); }
|
||||
.icon-call { background: rgba(250,173,20,0.1); }
|
||||
.icon-error { background: rgba(245,34,45,0.1); }
|
||||
.icon-time { background: rgba(114,46,209,0.1); }
|
||||
.stat-info { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-label { font-size: 13px; color: var(--text-secondary, #666); margin-top: 2px; }
|
||||
.stat-sub { font-size: 11px; color: var(--text-tertiary, #999); margin-top: 2px; }
|
||||
|
||||
/* Tabs */
|
||||
.dev-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border-color, #e8e8e8); margin-bottom: 20px; }
|
||||
.dev-tab {
|
||||
padding: 10px 20px; border: none; background: none; cursor: pointer;
|
||||
font-size: 14px; color: var(--text-secondary, #666);
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
|
||||
}
|
||||
.dev-tab.active { color: var(--primary, #1890ff); border-bottom-color: var(--primary, #1890ff); font-weight: 600; }
|
||||
.dev-tab:hover { color: var(--primary, #1890ff); }
|
||||
|
||||
/* Toolbar */
|
||||
.dev-toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.dev-input {
|
||||
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||
font-size: 14px; flex: 1; min-width: 200px; max-width: 400px;
|
||||
background: var(--input-bg, #fff); color: var(--text-primary, #333);
|
||||
}
|
||||
.dev-input:focus { outline: none; border-color: var(--primary, #1890ff); box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
|
||||
.dev-select {
|
||||
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||
font-size: 14px; background: var(--input-bg, #fff); color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.dev-btn {
|
||||
padding: 8px 16px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||
font-size: 14px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
|
||||
transition: all 0.2s; white-space: nowrap;
|
||||
}
|
||||
.dev-btn:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
|
||||
.dev-btn.primary { background: var(--primary, #1890ff); color: #fff; border-color: var(--primary, #1890ff); }
|
||||
.dev-btn.primary:hover { background: var(--primary-dark, #096dd9); color: #fff; }
|
||||
.dev-btn.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
|
||||
.dev-btn.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
|
||||
.dev-btn-sm {
|
||||
padding: 4px 10px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
|
||||
font-size: 12px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
|
||||
margin-right: 4px; transition: all 0.2s;
|
||||
}
|
||||
.dev-btn-sm:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
|
||||
.dev-btn-sm.warn { color: var(--warning, #faad14); border-color: var(--warning, #faad14); }
|
||||
.dev-btn-sm.warn:hover { background: var(--warning, #faad14); color: #fff; }
|
||||
.dev-btn-sm.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
|
||||
.dev-btn-sm.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
|
||||
|
||||
/* Table */
|
||||
.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.dev-table th {
|
||||
text-align: left; padding: 10px 12px; background: var(--table-header-bg, #fafafa);
|
||||
border-bottom: 2px solid var(--border-color, #e8e8e8); font-weight: 600; white-space: nowrap;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
.dev-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f0f0); }
|
||||
.dev-table tbody tr:hover { background: var(--table-hover-bg, #f5f5f5); }
|
||||
.mono-text { font-family: 'Courier New', monospace; font-size: 12px; }
|
||||
.dev-actions { white-space: nowrap; }
|
||||
.dev-empty { text-align: center; color: var(--text-tertiary, #999); padding: 40px 12px !important; }
|
||||
.dev-loading { text-align: center; padding: 40px; color: var(--text-secondary, #666); }
|
||||
|
||||
/* Badges */
|
||||
.dev-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.badge-active { background: rgba(82,196,26,0.1); color: #52c41a; }
|
||||
.badge-revoked { background: rgba(245,34,45,0.1); color: #f5222d; }
|
||||
.badge-disabled { background: rgba(250,173,20,0.1); color: #faad14; }
|
||||
|
||||
/* Method badges */
|
||||
.method-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
|
||||
.method-get { background: rgba(82,196,26,0.1); color: #52c41a; }
|
||||
.method-post { background: rgba(24,144,255,0.1); color: #1890ff; }
|
||||
.method-put { background: rgba(250,173,20,0.1); color: #faad14; }
|
||||
.method-delete { background: rgba(245,34,45,0.1); color: #f5222d; }
|
||||
.method-patch { background: rgba(114,46,209,0.1); color: #722ed1; }
|
||||
|
||||
/* Text colors */
|
||||
.text-danger { color: var(--danger, #f5222d); }
|
||||
.text-warn { color: var(--warning, #faad14); }
|
||||
|
||||
/* Pagination */
|
||||
.dev-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; }
|
||||
.dev-pagination button {
|
||||
padding: 6px 14px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
|
||||
cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333); font-size: 13px;
|
||||
}
|
||||
.dev-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.dev-pagination span { font-size: 13px; color: var(--text-secondary, #666); }
|
||||
|
||||
/* Modal */
|
||||
.dev-modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.dev-modal {
|
||||
background: var(--modal-bg, #fff); border-radius: 10px; padding: 24px;
|
||||
width: 460px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
.dev-modal h3 { margin: 0 0 20px 0; font-size: 18px; }
|
||||
.dev-form-group { margin-bottom: 16px; }
|
||||
.dev-form-group label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--text-secondary, #666); }
|
||||
.dev-form-group .dev-input { width: 100%; max-width: none; box-sizing: border-box; }
|
||||
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dev-stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.dev-table { font-size: 12px; }
|
||||
}
|
||||
|
||||
/* Phase 3.9: Developer Tools styles */
|
||||
.dev-method { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: monospace; }
|
||||
.method-get { background: rgba(82,196,26,0.2); color: #52c41a; }
|
||||
.method-post { background: rgba(22,119,255,0.2); color: #1677ff; }
|
||||
.method-put { background: rgba(250,173,20,0.2); color: #faad14; }
|
||||
.method-delete { background: rgba(255,77,79,0.2); color: #ff4d4f; }
|
||||
|
||||
.dev-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.dev-modal { background: #FFFFFF; border: 1px solid rgba(0,0,0,0.10); border-radius: 12px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
.dev-modal h3 { margin: 0 0 16px; color: #1A1F36; }
|
||||
.dev-form-group { margin-bottom: 14px; }
|
||||
.dev-form-group label { display: block; font-size: 13px; color: #5A6480; margin-bottom: 4px; }
|
||||
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
.dev-muted { color: #8892B0; font-size: 12px; }
|
||||
.dev-test-result { margin: 12px 0; padding: 12px; border-radius: 8px; font-size: 14px; }
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import './Developer.css';
|
||||
|
||||
const API_BASE = '/app/api';
|
||||
|
||||
interface Stats {
|
||||
api_keys: { total: number; active: number; revoked: number };
|
||||
openapi_clients: { total: number; active: number };
|
||||
call_stats: { total_calls: number; today_calls: number; total_errors: number; error_rate: number; avg_response_time: number };
|
||||
slow_logs: { total: number; today: number };
|
||||
top_endpoints: { endpoint: string; method: string; call_count: number; error_count: number }[];
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string; user_id: string; username: string | null; api_key: string; name: string;
|
||||
description: string; rate_limit: number; total_calls: number; status: string;
|
||||
last_used_at: string | null; created_at: string; expired_at: string | null;
|
||||
}
|
||||
|
||||
interface OpenApiClient {
|
||||
id: string; user_id: string; username: string | null; name: string; client_id: string;
|
||||
allowed_models: string[]; rate_limit: number; total_calls: number;
|
||||
last_used_at: string | null; status: string; created_at: string;
|
||||
}
|
||||
|
||||
interface CallStat {
|
||||
id: string; endpoint: string; method: string; call_count: number; error_count: number;
|
||||
error_rate: number; avg_response_time: number; total_response_time: number;
|
||||
last_called_at: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
interface SlowLog {
|
||||
id: string; request_id: string | null; method: string; endpoint: string;
|
||||
status_code: number; response_time: number; threshold: number; created_at: string;
|
||||
}
|
||||
|
||||
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
|
||||
|
||||
async function api(path: string, options?: RequestInit) {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 307) {
|
||||
const location = res.headers.get('Location');
|
||||
if (location) return api(location.replace(API_BASE, ''), options);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function AdminDeveloper() {
|
||||
const [activeTab, setActiveTab] = useState<'keys' | 'clients' | 'stats' | 'logs'>('keys');
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// API Keys state
|
||||
const [keys, setKeys] = useState<PageData<ApiKey>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [keySearch, setKeySearch] = useState('');
|
||||
const [keyStatusFilter, setKeyStatusFilter] = useState('');
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
const [keyForm, setKeyForm] = useState({ user_id: '', name: '', description: '', rate_limit: 60 });
|
||||
|
||||
// OpenAPI Clients state
|
||||
const [clients, setClients] = useState<PageData<OpenApiClient>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [clientSearch, setClientSearch] = useState('');
|
||||
const [showClientModal, setShowClientModal] = useState(false);
|
||||
const [clientForm, setClientForm] = useState({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
|
||||
|
||||
// Call Stats state
|
||||
const [callStats, setCallStats] = useState<PageData<CallStat>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [statSearch, setStatSearch] = useState('');
|
||||
const [statSortBy, setStatSortBy] = useState('call_count');
|
||||
|
||||
// Slow Logs state
|
||||
const [slowLogs, setSlowLogs] = useState<PageData<SlowLog>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [logSearch, setLogSearch] = useState('');
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer/stats/overview');
|
||||
if (res.code === 0) setStats(res.data);
|
||||
} catch (e) { console.error('Stats error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadKeys = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||
if (keyStatusFilter) params.set('status', keyStatusFilter);
|
||||
if (keySearch) params.set('keyword', keySearch);
|
||||
const res = await api(`/admin/developer/api-keys?${params}`);
|
||||
if (res.code === 0) setKeys(res.data);
|
||||
} catch (e) { console.error('Keys error:', e); }
|
||||
setLoading(false);
|
||||
}, [keySearch, keyStatusFilter]);
|
||||
|
||||
const loadClients = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||
if (clientSearch) params.set('keyword', clientSearch);
|
||||
const res = await api(`/admin/developer/openapi-clients?${params}`);
|
||||
if (res.code === 0) setClients(res.data);
|
||||
} catch (e) { console.error('Clients error:', e); }
|
||||
setLoading(false);
|
||||
}, [clientSearch]);
|
||||
|
||||
const loadCallStats = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: '20', sort_by: statSortBy });
|
||||
if (statSearch) params.set('keyword', statSearch);
|
||||
const res = await api(`/admin/developer/call-stats?${params}`);
|
||||
if (res.code === 0) setCallStats(res.data);
|
||||
} catch (e) { console.error('Stats error:', e); }
|
||||
setLoading(false);
|
||||
}, [statSearch, statSortBy]);
|
||||
|
||||
const loadSlowLogs = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||
if (logSearch) params.set('keyword', logSearch);
|
||||
const res = await api(`/admin/developer/slow-logs?${params}`);
|
||||
if (res.code === 0) setSlowLogs(res.data);
|
||||
} catch (e) { console.error('Logs error:', e); }
|
||||
setLoading(false);
|
||||
}, [logSearch]);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
useEffect(() => { if (activeTab === 'keys') loadKeys(); }, [activeTab, loadKeys]);
|
||||
useEffect(() => { if (activeTab === 'clients') loadClients(); }, [activeTab, loadClients]);
|
||||
useEffect(() => { if (activeTab === 'stats') loadCallStats(); }, [activeTab, loadCallStats]);
|
||||
useEffect(() => { if (activeTab === 'logs') loadSlowLogs(); }, [activeTab, loadSlowLogs]);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
try {
|
||||
const body: any = { user_id: keyForm.user_id, name: keyForm.name, description: keyForm.description, rate_limit: keyForm.rate_limit };
|
||||
await api('/admin/developer/api-keys/', { method: 'POST', body: JSON.stringify(body) });
|
||||
setShowKeyModal(false);
|
||||
setKeyForm({ user_id: '', name: '', description: '', rate_limit: 60 });
|
||||
loadKeys();
|
||||
loadStats();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleRevokeKey = async (id: string) => {
|
||||
if (!confirm('Revoke this API key?')) return;
|
||||
try {
|
||||
await api(`/admin/developer/api-keys/${id}/revoke`, { method: 'POST' });
|
||||
loadKeys(); loadStats();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
if (!confirm('Permanently delete this API key?')) return;
|
||||
try {
|
||||
await api(`/admin/developer/api-keys/${id}`, { method: 'DELETE' });
|
||||
loadKeys(); loadStats();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleCreateClient = async () => {
|
||||
try {
|
||||
const models = clientForm.allowed_models ? clientForm.allowed_models.split(',').map(s => s.trim()).filter(Boolean) : null;
|
||||
const body: any = { user_id: clientForm.user_id, name: clientForm.name, allowed_models: models, rate_limit: clientForm.rate_limit };
|
||||
const res = await api('/admin/developer/openapi-clients/', { method: 'POST', body: JSON.stringify(body) });
|
||||
setShowClientModal(false);
|
||||
setClientForm({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
|
||||
loadClients(); loadStats();
|
||||
if (res.data?.client_secret) {
|
||||
alert(`Client created! Secret: ${res.data.client_secret}\nSave this secret - it won't be shown again.`);
|
||||
}
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleToggleClient = async (client: OpenApiClient) => {
|
||||
const newStatus = client.status === 'active' ? 'disabled' : 'active';
|
||||
try {
|
||||
await api(`/admin/developer/openapi-clients/${client.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||
loadClients();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteClient = async (id: string) => {
|
||||
if (!confirm('Delete this OpenAPI client?')) return;
|
||||
try {
|
||||
await api(`/admin/developer/openapi-clients/${id}`, { method: 'DELETE' });
|
||||
loadClients(); loadStats();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleResetSecret = async (id: string) => {
|
||||
if (!confirm('Reset client secret? The old secret will be invalidated.')) return;
|
||||
try {
|
||||
const res = await api(`/admin/developer/openapi-clients/${id}/reset-secret`, { method: 'POST' });
|
||||
if (res.data?.client_secret) alert(`New secret: ${res.data.client_secret}`);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
if (!confirm('Clear ALL slow request logs?')) return;
|
||||
try {
|
||||
await api('/admin/developer/slow-logs', { method: 'DELETE' });
|
||||
loadSlowLogs(); loadStats();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const fmtTime = (t: string | null) => {
|
||||
if (!t) return '-';
|
||||
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const cls = status === 'active' ? 'badge-active' : status === 'revoked' ? 'badge-revoked' : 'badge-disabled';
|
||||
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-developer">
|
||||
<div className="dev-header">
|
||||
<h2>开发者API管理</h2>
|
||||
<p>管理API密钥、OpenAPI客户端、调用统计和慢请求日志</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="dev-stats-grid">
|
||||
<div className="dev-stat-card">
|
||||
<div className="stat-icon icon-key">🔑</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.api_keys.total}</span>
|
||||
<span className="stat-label">API密钥总数</span>
|
||||
<span className="stat-sub">活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-stat-card">
|
||||
<div className="stat-icon icon-client">🔌</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.openapi_clients.total}</span>
|
||||
<span className="stat-label">OpenAPI客户端</span>
|
||||
<span className="stat-sub">活跃 {stats.openapi_clients.active}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-stat-card">
|
||||
<div className="stat-icon icon-call">📞</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.call_stats.total_calls.toLocaleString()}</span>
|
||||
<span className="stat-label">API总调用</span>
|
||||
<span className="stat-sub">今日 {stats.call_stats.today_calls.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-stat-card">
|
||||
<div className="stat-icon icon-error">⚠️</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.call_stats.error_rate}%</span>
|
||||
<span className="stat-label">错误率</span>
|
||||
<span className="stat-sub">错误 {stats.call_stats.total_errors.toLocaleString()} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-stat-card">
|
||||
<div className="stat-icon icon-time">⏱️</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.call_stats.avg_response_time}s</span>
|
||||
<span className="stat-label">平均响应时间</span>
|
||||
<span className="stat-sub">慢请求 {stats.slow_logs.total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="dev-tabs">
|
||||
<button className={`dev-tab ${activeTab === 'keys' ? 'active' : ''}`} onClick={() => setActiveTab('keys')}>API密钥</button>
|
||||
<button className={`dev-tab ${activeTab === 'clients' ? 'active' : ''}`} onClick={() => setActiveTab('clients')}>OpenAPI客户端</button>
|
||||
<button className={`dev-tab ${activeTab === 'stats' ? 'active' : ''}`} onClick={() => setActiveTab('stats')}>调用统计</button>
|
||||
<button className={`dev-tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>慢请求日志</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="dev-tab-content">
|
||||
{loading && <div className="dev-loading">加载中...</div>}
|
||||
|
||||
{/* API Keys Tab */}
|
||||
{activeTab === 'keys' && !loading && (
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<input className="dev-input" placeholder="搜索密钥名称/用户..." value={keySearch} onChange={e => setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} />
|
||||
<select className="dev-select" value={keyStatusFilter} onChange={e => setKeyStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="revoked">已撤销</option>
|
||||
</select>
|
||||
<button className="dev-btn primary" onClick={() => setShowKeyModal(true)}>+ 新建密钥</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>API Key</th><th>用户</th><th>速率限制</th><th>状态</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.items.map(k => (
|
||||
<tr key={k.id}>
|
||||
<td>{k.name || '-'}</td>
|
||||
<td className="mono-text">{k.api_key.slice(0, 12)}...</td>
|
||||
<td>{k.username || k.user_id.slice(0, 8)}</td>
|
||||
<td>{k.rate_limit}/min</td>
|
||||
<td>{statusBadge(k.status)}</td>
|
||||
<td>{fmtTime(k.last_used_at)}</td>
|
||||
<td>{fmtTime(k.created_at)}</td>
|
||||
<td className="dev-actions">
|
||||
{k.status === 'active' && <button className="dev-btn-sm warn" onClick={() => handleRevokeKey(k.id)}>撤销</button>}
|
||||
<button className="dev-btn-sm danger" onClick={() => handleDeleteKey(k.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{keys.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无API密钥</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{keys.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={keys.page <= 1} onClick={() => loadKeys(keys.page - 1)}>上一页</button>
|
||||
<span>第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页</span>
|
||||
<button disabled={keys.page >= Math.ceil(keys.total / 20)} onClick={() => loadKeys(keys.page + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAPI Clients Tab */}
|
||||
{activeTab === 'clients' && !loading && (
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<input className="dev-input" placeholder="搜索客户端名称/ID..." value={clientSearch} onChange={e => setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} />
|
||||
<button className="dev-btn primary" onClick={() => setShowClientModal(true)}>+ 新建客户端</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>Client ID</th><th>用户</th><th>允许模型</th><th>速率限制</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.items.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td className="mono-text">{c.client_id}</td>
|
||||
<td>{c.username || c.user_id.slice(0, 8)}</td>
|
||||
<td>{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}</td>
|
||||
<td>{c.rate_limit}/min</td>
|
||||
<td>{statusBadge(c.status)}</td>
|
||||
<td>{fmtTime(c.created_at)}</td>
|
||||
<td className="dev-actions">
|
||||
<button className="dev-btn-sm" onClick={() => handleToggleClient(c)}>{c.status === 'active' ? '禁用' : '启用'}</button>
|
||||
<button className="dev-btn-sm warn" onClick={() => handleResetSecret(c.id)}>重置密钥</button>
|
||||
<button className="dev-btn-sm danger" onClick={() => handleDeleteClient(c.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{clients.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无OpenAPI客户端</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{clients.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={clients.page <= 1} onClick={() => loadClients(clients.page - 1)}>上一页</button>
|
||||
<span>第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页</span>
|
||||
<button disabled={clients.page >= Math.ceil(clients.total / 20)} onClick={() => loadClients(clients.page + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call Stats Tab */}
|
||||
{activeTab === 'stats' && !loading && (
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<input className="dev-input" placeholder="搜索端点..." value={statSearch} onChange={e => setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} />
|
||||
<select className="dev-select" value={statSortBy} onChange={e => setStatSortBy(e.target.value)}>
|
||||
<option value="call_count">按调用次数</option>
|
||||
<option value="error_count">按错误数</option>
|
||||
<option value="total_response_time">按总耗时</option>
|
||||
<option value="last_called_at">按最后调用</option>
|
||||
</select>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>端点</th><th>方法</th><th>调用次数</th><th>错误数</th><th>错误率</th><th>平均耗时</th><th>最后调用</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callStats.items.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td className="mono-text">{s.endpoint}</td>
|
||||
<td><span className={`method-badge method-${s.method.toLowerCase()}`}>{s.method}</span></td>
|
||||
<td>{s.call_count.toLocaleString()}</td>
|
||||
<td className={s.error_count > 0 ? 'text-danger' : ''}>{s.error_count}</td>
|
||||
<td className={s.error_rate > 5 ? 'text-danger' : ''}>{s.error_rate}%</td>
|
||||
<td>{s.avg_response_time}s</td>
|
||||
<td>{fmtTime(s.last_called_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{callStats.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无调用统计</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{callStats.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={callStats.page <= 1} onClick={() => loadCallStats(callStats.page - 1)}>上一页</button>
|
||||
<span>第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页</span>
|
||||
<button disabled={callStats.page >= Math.ceil(callStats.total / 20)} onClick={() => loadCallStats(callStats.page + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slow Logs Tab */}
|
||||
{activeTab === 'logs' && !loading && (
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<input className="dev-input" placeholder="搜索端点..." value={logSearch} onChange={e => setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} />
|
||||
<button className="dev-btn danger" onClick={handleClearLogs}>清空日志</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>请求ID</th><th>方法</th><th>端点</th><th>状态码</th><th>响应时间</th><th>阈值</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{slowLogs.items.map(l => (
|
||||
<tr key={l.id}>
|
||||
<td className="mono-text">{l.request_id?.slice(0, 12) || '-'}</td>
|
||||
<td><span className={`method-badge method-${l.method.toLowerCase()}`}>{l.method}</span></td>
|
||||
<td className="mono-text">{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}</td>
|
||||
<td className={l.status_code >= 400 ? 'text-danger' : ''}>{l.status_code}</td>
|
||||
<td className="text-warn">{l.response_time}s</td>
|
||||
<td>{l.threshold}s</td>
|
||||
<td>{fmtTime(l.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{slowLogs.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无慢请求日志</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{slowLogs.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={slowLogs.page <= 1} onClick={() => loadSlowLogs(slowLogs.page - 1)}>上一页</button>
|
||||
<span>第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页</span>
|
||||
<button disabled={slowLogs.page >= Math.ceil(slowLogs.total / 20)} onClick={() => loadSlowLogs(slowLogs.page + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
{showKeyModal && (
|
||||
<div className="dev-modal-overlay" onClick={() => setShowKeyModal(false)}>
|
||||
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>新建API密钥</h3>
|
||||
<div className="dev-form-group">
|
||||
<label>用户ID *</label>
|
||||
<input className="dev-input" value={keyForm.user_id} onChange={e => setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>名称</label>
|
||||
<input className="dev-input" value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>描述</label>
|
||||
<input className="dev-input" value={keyForm.description} onChange={e => setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>速率限制 (请求/分钟)</label>
|
||||
<input type="number" className="dev-input" value={keyForm.rate_limit} onChange={e => setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} />
|
||||
</div>
|
||||
<div className="dev-modal-actions">
|
||||
<button className="dev-btn" onClick={() => setShowKeyModal(false)}>取消</button>
|
||||
<button className="dev-btn primary" onClick={handleCreateKey} disabled={!keyForm.user_id}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Client Modal */}
|
||||
{showClientModal && (
|
||||
<div className="dev-modal-overlay" onClick={() => setShowClientModal(false)}>
|
||||
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>新建OpenAPI客户端</h3>
|
||||
<div className="dev-form-group">
|
||||
<label>用户ID *</label>
|
||||
<input className="dev-input" value={clientForm.user_id} onChange={e => setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>名称 *</label>
|
||||
<input className="dev-input" value={clientForm.name} onChange={e => setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>允许的模型 (逗号分隔,留空=全部)</label>
|
||||
<input className="dev-input" value={clientForm.allowed_models} onChange={e => setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>速率限制 (请求/分钟)</label>
|
||||
<input type="number" className="dev-input" value={clientForm.rate_limit} onChange={e => setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} />
|
||||
</div>
|
||||
<div className="dev-modal-actions">
|
||||
<button className="dev-btn" onClick={() => setShowClientModal(false)}>取消</button>
|
||||
<button className="dev-btn primary" onClick={handleCreateClient} disabled={!clientForm.user_id || !clientForm.name}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import './Developer.css';
|
||||
|
||||
const API_BASE = '/app/api';
|
||||
|
||||
interface Webhook {
|
||||
id: string; name: string; url: string; events: string[];
|
||||
has_secret: boolean; headers: Record<string, string>; status: string;
|
||||
last_triggered_at: string | null; last_status: string | null;
|
||||
total_deliveries: number; success_count: number; fail_count: number;
|
||||
success_rate: number; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
interface WebhookEvent {
|
||||
value: string; label: string; desc: string;
|
||||
}
|
||||
|
||||
interface WebhookDelivery {
|
||||
id: string; event: string; status: string; response_code: number | null;
|
||||
response_time_ms: number | null; attempts: number; error_message: string | null;
|
||||
payload: any; response_body: string; created_at: string;
|
||||
}
|
||||
|
||||
interface SDKPackage {
|
||||
language: string; name: string; version: string; description: string;
|
||||
install_cmd: string; download_url: string; github_url: string;
|
||||
min_version: string; features: string[]; code_example: string;
|
||||
}
|
||||
|
||||
interface ApiDocSection {
|
||||
section: string; icon: string; description: string;
|
||||
endpoints: Array<{
|
||||
method: string; path: string; title: string; description: string;
|
||||
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
|
||||
example_request: string; example_response: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
|
||||
|
||||
async function api(path: string, options?: RequestInit) {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 307) {
|
||||
const location = res.headers.get('Location');
|
||||
if (location) return api(location.replace(API_BASE, ''), options);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function AdminDeveloperTools() {
|
||||
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Webhooks state
|
||||
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
|
||||
const [showWebhookModal, setShowWebhookModal] = useState(false);
|
||||
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
||||
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
|
||||
// SDK state
|
||||
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
|
||||
const [selectedSdk, setSelectedSdk] = useState<string>('python');
|
||||
|
||||
// API Docs state
|
||||
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
|
||||
const loadWebhooks = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setWebhooks(res.data);
|
||||
} catch (e) { console.error('Webhooks error:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadWebhookEvents = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/webhooks/events');
|
||||
if (res.code === 0) setWebhookEvents(res.data.events);
|
||||
} catch (e) { console.error('Events error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setDeliveries(res.data);
|
||||
} catch (e) { console.error('Deliveries error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadSdkPackages = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/sdk/packages');
|
||||
if (res.code === 0) setSdkPackages(res.data.packages);
|
||||
} catch (e) { console.error('SDK error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadApiDocs = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/api-docs');
|
||||
if (res.code === 0) setDocSections(res.data.sections);
|
||||
} catch (e) { console.error('Docs error:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
|
||||
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
|
||||
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
|
||||
|
||||
const handleCreateWebhook = async () => {
|
||||
try {
|
||||
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
|
||||
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
|
||||
setShowWebhookModal(false);
|
||||
setWebhookForm({ name: '', url: '', events: [] });
|
||||
loadWebhooks();
|
||||
if (res.data?.secret) {
|
||||
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
|
||||
}
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: string) => {
|
||||
if (!confirm('确定删除此 Webhook?')) return;
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
|
||||
loadWebhooks();
|
||||
setSelectedWebhook(null);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleToggleWebhook = async (wh: Webhook) => {
|
||||
const newStatus = wh.status === 'active' ? 'paused' : 'active';
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleTestWebhook = async (id: string) => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
|
||||
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
|
||||
});
|
||||
setTestResult(res.data);
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleViewDeliveries = (wh: Webhook) => {
|
||||
setSelectedWebhook(wh);
|
||||
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
loadDeliveries(wh.id);
|
||||
};
|
||||
|
||||
const fmtTime = (t: string | null) => {
|
||||
if (!t) return '-';
|
||||
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const cls = status === 'active' ? 'badge-active' : status === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
|
||||
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||
};
|
||||
|
||||
const methodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
|
||||
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Copied to clipboard');
|
||||
}).catch(() => alert('Copy failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-developer">
|
||||
<div className="dev-header">
|
||||
<h2>开发者工具</h2>
|
||||
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="dev-tabs">
|
||||
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
|
||||
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
|
||||
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="dev-tab-content">
|
||||
{loading && <div className="dev-loading">加载中...</div>}
|
||||
|
||||
{/* ===== Webhooks Tab ===== */}
|
||||
{activeTab === 'webhooks' && !loading && (
|
||||
<div>
|
||||
{selectedWebhook ? (
|
||||
/* Webhook Detail / Deliveries View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
|
||||
← Back to list
|
||||
</button>
|
||||
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
|
||||
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
|
||||
<strong>Test Result: </strong>
|
||||
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
|
||||
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
|
||||
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
|
||||
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveries.items.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td className="mono-text">{d.event}</td>
|
||||
<td>{statusBadge(d.status)}</td>
|
||||
<td>{d.response_code || '-'}</td>
|
||||
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
|
||||
<td>{d.attempts}</td>
|
||||
<td>{fmtTime(d.created_at)}</td>
|
||||
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{deliveries.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
|
||||
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
|
||||
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Webhook List View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webhooks.items.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
|
||||
<td>
|
||||
{w.events.length > 0 ? (
|
||||
<span>{w.events.length} events</span>
|
||||
) : <span className="dev-muted">All events</span>}
|
||||
</td>
|
||||
<td>{statusBadge(w.status)}</td>
|
||||
<td>{w.total_deliveries}</td>
|
||||
<td>
|
||||
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
|
||||
{w.success_rate}%
|
||||
</span>
|
||||
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
|
||||
</td>
|
||||
<td>{fmtTime(w.last_triggered_at)}</td>
|
||||
<td className="dev-actions">
|
||||
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||||
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{webhooks.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
|
||||
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
|
||||
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支持的事件 Info */}
|
||||
{webhookEvents.length > 0 && (
|
||||
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
|
||||
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
|
||||
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Webhook Modal */}
|
||||
{showWebhookModal && (
|
||||
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
|
||||
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>新建 Webhook</h3>
|
||||
<div className="dev-form-group">
|
||||
<label>名称</label>
|
||||
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>URL</label>
|
||||
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>Events (leave empty for all)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
|
||||
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
|
||||
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
|
||||
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
|
||||
}} />
|
||||
{ev.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-modal-actions">
|
||||
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
|
||||
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== SDK Download Center Tab ===== */}
|
||||
{activeTab === 'sdk' && !loading && (
|
||||
<div>
|
||||
{/* Language Selector */}
|
||||
<div className="dev-toolbar">
|
||||
{sdkPackages.map(pkg => (
|
||||
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
|
||||
{pkg.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
|
||||
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
|
||||
{/* Package Info */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
|
||||
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
|
||||
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install Command */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
{pkg.install_cmd}
|
||||
</code>
|
||||
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能特性 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{pkg.features.map(f => (
|
||||
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Example */}
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
|
||||
<code>{pkg.code_example}</code>
|
||||
</pre>
|
||||
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Interactive API Docs Tab ===== */}
|
||||
{activeTab === 'docs' && !loading && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
|
||||
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
|
||||
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
|
||||
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
|
||||
</div>
|
||||
|
||||
{docSections.map(section => {
|
||||
const isExpanded = expandedSection === section.section;
|
||||
return (
|
||||
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
|
||||
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '18px' }}>
|
||||
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
|
||||
</span>
|
||||
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
|
||||
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
|
||||
</div>
|
||||
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
{isExpanded && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
|
||||
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
|
||||
{section.endpoints.map(ep => {
|
||||
const epKey = `${section.section}-${ep.path}`;
|
||||
const isEpExpanded = expandedEndpoint === epKey;
|
||||
return (
|
||||
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
|
||||
<div
|
||||
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
|
||||
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
{methodBadge(ep.method)}
|
||||
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
|
||||
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{isEpExpanded && (
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
|
||||
|
||||
{/* 参数 */}
|
||||
{ep.params.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
|
||||
<table className="dev-table" style={{ fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.params.map(p => (
|
||||
<tr key={p.name}>
|
||||
<td className="mono-text">{p.name}</td>
|
||||
<td>{p.type}</td>
|
||||
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
|
||||
<td>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 请求示例 */}
|
||||
{ep.example_request && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_request}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 响应示例 */}
|
||||
{ep.example_response && (
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_response}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
|
||||
|
||||
interface Distribution {
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
description: string;
|
||||
|
||||
brand_name: string;
|
||||
|
||||
brand_logo_url: string;
|
||||
|
||||
brand_color: string;
|
||||
|
||||
system_prompt: string;
|
||||
|
||||
provider: string;
|
||||
|
||||
model: string;
|
||||
|
||||
mcp_extensions: any[];
|
||||
|
||||
workflow_ids: string[];
|
||||
|
||||
knowledge_base_ids: string[];
|
||||
|
||||
agent_ids: string[];
|
||||
|
||||
features: string[];
|
||||
|
||||
status: string;
|
||||
|
||||
version: string;
|
||||
|
||||
download_count: number;
|
||||
|
||||
size_bytes: number;
|
||||
|
||||
license_count?: number;
|
||||
|
||||
created_at: string;
|
||||
|
||||
updated_at: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface License {
|
||||
|
||||
id: string;
|
||||
|
||||
distribution_id: string;
|
||||
|
||||
distribution_name: string;
|
||||
|
||||
org_name: string;
|
||||
|
||||
licensee_email: string;
|
||||
|
||||
license_key: string;
|
||||
|
||||
features: string[];
|
||||
|
||||
max_users: number;
|
||||
|
||||
max_executions: number;
|
||||
|
||||
usage_count: number;
|
||||
|
||||
expiry_date: string | null;
|
||||
|
||||
status: string;
|
||||
|
||||
created_at: string;
|
||||
|
||||
last_used_at: string | null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Stats {
|
||||
|
||||
total: number;
|
||||
|
||||
draft: number;
|
||||
|
||||
active: number;
|
||||
|
||||
archived: number;
|
||||
|
||||
total_downloads: number;
|
||||
|
||||
total_licenses: number;
|
||||
|
||||
active_licenses: number;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Templates {
|
||||
|
||||
workflows: any[];
|
||||
|
||||
agents: any[];
|
||||
|
||||
knowledge_bases: any[];
|
||||
|
||||
mcp_tools: any[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '已发布', archived: '已归档' };
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' };
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
|
||||
chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索',
|
||||
|
||||
agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default function DistributionBuilder() {
|
||||
|
||||
const [distributions, setDistributions] = useState<Distribution[]>([]);
|
||||
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 });
|
||||
|
||||
const [templates, setTemplates] = useState<Templates>({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] });
|
||||
|
||||
const [licenses, setLicenses] = useState<License[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions');
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const [showLicense, setShowLicense] = useState(false);
|
||||
|
||||
const [selectedDistro, setSelectedDistro] = useState<string>('');
|
||||
|
||||
const [editDistro, setEditDistro] = useState<Distribution | null>(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [licPage, setLicPage] = useState(1);
|
||||
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const [licTotalPages, setLicTotalPages] = useState(1);
|
||||
|
||||
|
||||
|
||||
// Form state
|
||||
|
||||
const [form, setForm] = useState({
|
||||
|
||||
name: '', description: '', brand_name: '', brand_color: '#6C63FF',
|
||||
|
||||
system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash',
|
||||
|
||||
features: ['chat'] as string[], version: '1.0.0',
|
||||
|
||||
workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[],
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
// License form
|
||||
|
||||
const [licForm, setLicForm] = useState({
|
||||
|
||||
org_name: '', licensee_email: '', max_users: 10, max_executions: 10000,
|
||||
|
||||
expiry_date: '', features: ['chat'] as string[],
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const loadData = async () => {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
|
||||
const [s, d, t] = await Promise.all([
|
||||
|
||||
adminAPI.getDistributionStats(),
|
||||
|
||||
adminAPI.getDistributions({ page, page_size: 20 }),
|
||||
|
||||
adminAPI.getDistributionTemplates(),
|
||||
|
||||
]);
|
||||
|
||||
if (s.code === 0) setStats(s.data);
|
||||
|
||||
if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); }
|
||||
|
||||
if (t.code === 0) setTemplates(t.data);
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
setLoading(false);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const loadLicenses = async () => {
|
||||
|
||||
try {
|
||||
|
||||
const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 });
|
||||
|
||||
if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); }
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => { loadData(); }, [page]);
|
||||
|
||||
useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]);
|
||||
|
||||
|
||||
|
||||
const handleCreate = async () => {
|
||||
|
||||
try {
|
||||
|
||||
const res = await adminAPI.createDistribution(form);
|
||||
|
||||
if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); }
|
||||
|
||||
else alert('创建失败: ' + (res.message || ''));
|
||||
|
||||
} catch (e) { alert('创建失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
||||
if (!editDistro) return;
|
||||
|
||||
try {
|
||||
|
||||
const res = await adminAPI.updateDistribution(editDistro.id, form);
|
||||
|
||||
if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); }
|
||||
|
||||
else alert('更新失败');
|
||||
|
||||
} catch (e) { alert('更新失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
|
||||
if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return;
|
||||
|
||||
try {
|
||||
|
||||
await adminAPI.deleteDistribution(id);
|
||||
|
||||
loadData();
|
||||
|
||||
} catch (e) { alert('删除失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleBuild = async (id: string) => {
|
||||
|
||||
try {
|
||||
|
||||
const token = localStorage.getItem('token') || '';
|
||||
|
||||
window.open(`/app/api/admin/distributions/${id}/build`, '_blank');
|
||||
|
||||
} catch (e) { alert('下载失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleGenerateLicense = async () => {
|
||||
|
||||
if (!selectedDistro) return;
|
||||
|
||||
try {
|
||||
|
||||
const res = await adminAPI.createDistributionLicense(selectedDistro, licForm);
|
||||
|
||||
if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); }
|
||||
|
||||
else alert('生成失败: ' + (res.message || ''));
|
||||
|
||||
} catch (e) { alert('生成失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleRevokeLicense = async (id: string) => {
|
||||
|
||||
if (!confirm('确定吊销此许可证?')) return;
|
||||
|
||||
try {
|
||||
|
||||
await adminAPI.revokeDistributionLicense(id);
|
||||
|
||||
loadLicenses();
|
||||
|
||||
} catch (e) { alert('吊销失败'); }
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const resetForm = () => {
|
||||
|
||||
setForm({ name: '', description: '', brand_name: '', brand_color: '#6C63FF', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] });
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const openEdit = (d: Distribution) => {
|
||||
|
||||
setEditDistro(d);
|
||||
|
||||
setForm({
|
||||
|
||||
name: d.name, description: d.description || '', brand_name: d.brand_name || '',
|
||||
|
||||
brand_color: d.brand_color || '#6C63FF', system_prompt: d.system_prompt || '',
|
||||
|
||||
provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash',
|
||||
|
||||
features: d.features || ['chat'], version: d.version || '1.0.0',
|
||||
|
||||
workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [],
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toggleFeature = (feat: string) => {
|
||||
|
||||
setForm(prev => ({
|
||||
|
||||
...prev,
|
||||
|
||||
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
|
||||
|
||||
}));
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toggleLicFeature = (feat: string) => {
|
||||
|
||||
setLicForm(prev => ({
|
||||
|
||||
...prev,
|
||||
|
||||
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
|
||||
|
||||
}));
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isEditing = !!editDistro;
|
||||
|
||||
|
||||
|
||||
// Styles
|
||||
|
||||
const dark = { accent: '#6C63FF', red: '#EF4444', green: '#10B981', yellow: '#F59E0B', bg: '#F5F7FC', card: '#FFFFFF', cardBg: '#FFFFFF', border: 'rgba(0,0,0,0.08)', text: '#1A1F36', sub: '#8892B0', textSecondary: '#5A6480', textTertiary: '#8892B0' };
|
||||
|
||||
|
||||
|
||||
const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 12, padding: 20 };
|
||||
|
||||
const btnPrimary: React.CSSProperties = { background: '#6C63FF', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
|
||||
|
||||
const btnDanger: React.CSSProperties = { background: '#EF4444', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
|
||||
|
||||
const btnGhost: React.CSSProperties = { background: 'transparent', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 };
|
||||
|
||||
const inputStyle: React.CSSProperties = { background: '#FFFFFF', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' };
|
||||
|
||||
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' };
|
||||
|
||||
const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' };
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ padding: 24, background: '#FFFFFF', minHeight: '100vh', color: '#1A1F36', fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
|
||||
|
||||
{/* Header */}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
|
||||
<div>
|
||||
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>发行版构建器</h1>
|
||||
|
||||
<p style={{ margin: '4px 0 0', color: dark.sub, fontSize: 13 }}>SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理</p>
|
||||
|
||||
</div>
|
||||
|
||||
<button onClick={() => { setShowCreate(true); setEditDistro(null); resetForm(); }} style={btnPrimary}>
|
||||
|
||||
+ 新建发行版
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Stats */}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
|
||||
{[
|
||||
|
||||
{ label: '发行版总数', value: stats.total },
|
||||
|
||||
{ label: '已发布', value: stats.active, color: dark.green },
|
||||
|
||||
{ label: '累计下载', value: stats.total_downloads, color: '#6C63FF' },
|
||||
|
||||
{ label: '有效许可证', value: stats.active_licenses, color: dark.yellow },
|
||||
|
||||
].map((s, i) => (
|
||||
|
||||
<div key={i} style={cardStyle}>
|
||||
|
||||
<div style={{ color: dark.sub, fontSize: 13 }}>{s.label}</div>
|
||||
|
||||
<div style={{ fontSize: 32, fontWeight: 700, color: s.color || '#1A1F36', marginTop: 4 }}>{s.value}</div>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${'rgba(0,0,0,0.08)'}` }}>
|
||||
|
||||
{[
|
||||
|
||||
{ key: 'distributions', label: '发行版管理' },
|
||||
|
||||
{ key: 'licenses', label: '许可证管理' },
|
||||
|
||||
].map(t => (
|
||||
|
||||
<button key={t.key}
|
||||
|
||||
onClick={() => setTab(t.key as any)}
|
||||
|
||||
style={{
|
||||
|
||||
padding: '10px 24px', background: 'transparent', border: 'none',
|
||||
|
||||
color: tab === t.key ? '#6C63FF' : dark.sub,
|
||||
|
||||
borderBottom: tab === t.key ? `2px solid ${'#6C63FF'}` : '2px solid transparent',
|
||||
|
||||
cursor: 'pointer', fontSize: 15, fontWeight: tab === t.key ? 600 : 400,
|
||||
|
||||
}}>
|
||||
|
||||
{t.label}
|
||||
|
||||
</button>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Distributions Tab */}
|
||||
|
||||
{tab === 'distributions' && (
|
||||
|
||||
<>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
|
||||
{distributions.map(d => (
|
||||
|
||||
<div key={d.id} style={{ ...cardStyle, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
|
||||
<div style={{ width: 48, height: 48, borderRadius: 10, background: d.brand_color || '#6C63FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
|
||||
|
||||
{(d.brand_name || d.name)[0]}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>{d.name}</span>
|
||||
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: STATUS_COLORS[d.status] + '22', color: STATUS_COLORS[d.status], fontWeight: 600 }}>
|
||||
|
||||
{STATUS_LABELS[d.status] || d.status}
|
||||
|
||||
</span>
|
||||
|
||||
<span style={{ color: dark.sub, fontSize: 12 }}>v{d.version}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ color: dark.sub, fontSize: 13, marginTop: 2 }}>{d.description || '无描述'}</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 6, fontSize: 12, color: dark.sub }}>
|
||||
|
||||
{d.features?.map(f => (
|
||||
|
||||
<span key={f}>✓ {FEATURE_LABELS[f] || f}</span>
|
||||
|
||||
))}
|
||||
|
||||
<span>下载 {d.download_count || 0} 次</span>
|
||||
|
||||
<span>{d.license_count || 0} 个许可证</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
|
||||
<button onClick={() => handleBuild(d.id)} style={btnGhost}>下载配置包</button>
|
||||
|
||||
<button onClick={() => { setSelectedDistro(d.id); setShowLicense(true); }} style={btnGhost}>生成许可证</button>
|
||||
|
||||
<button onClick={() => openEdit(d)} style={btnGhost}>编辑</button>
|
||||
|
||||
<button onClick={() => handleDelete(d.id)} style={btnDanger}>删除</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
|
||||
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={btnGhost}>上一页</button>
|
||||
|
||||
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {page}/{totalPages} 页</span>
|
||||
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} style={btnGhost}>下一页</button>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Licenses Tab */}
|
||||
|
||||
{tab === 'licenses' && (
|
||||
|
||||
<>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
|
||||
<button onClick={() => setShowLicense(true)} style={btnPrimary}>+ 生成许可证</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
|
||||
<thead>
|
||||
|
||||
<tr style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
|
||||
|
||||
{['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => (
|
||||
|
||||
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', color: dark.sub, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
|
||||
|
||||
))}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{licenses.map(lic => (
|
||||
|
||||
<tr key={lic.id} style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
|
||||
|
||||
<td style={{ padding: '10px 12px', fontFamily: 'monospace', fontSize: 12, color: '#6C63FF' }}>{lic.license_key}</td>
|
||||
|
||||
<td style={{ padding: '10px 12px' }}>{lic.org_name}</td>
|
||||
|
||||
<td style={{ padding: '10px 12px' }}>{lic.distribution_name}</td>
|
||||
|
||||
<td style={{ padding: '10px 12px', color: dark.sub }}>{lic.usage_count}/{lic.max_executions}</td>
|
||||
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: lic.status === 'active' ? '#3fb95022' : '#f8514922', color: lic.status === 'active' ? dark.green : '#EF4444' }}>{lic.status}</span>
|
||||
|
||||
</td>
|
||||
|
||||
<td style={{ padding: '10px 12px', color: dark.sub, fontSize: 12 }}>{lic.expiry_date || '永久'}</td>
|
||||
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
|
||||
{lic.status === 'active' && (
|
||||
|
||||
<button onClick={() => handleRevokeLicense(lic.id)} style={{ ...btnDanger, padding: '4px 12px', fontSize: 12 }}>吊销</button>
|
||||
|
||||
)}
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
))}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
{licTotalPages > 1 && (
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
|
||||
|
||||
<button onClick={() => setLicPage(p => Math.max(1, p - 1))} disabled={licPage <= 1} style={btnGhost}>上一页</button>
|
||||
|
||||
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {licPage}/{licTotalPages} 页</span>
|
||||
|
||||
<button onClick={() => setLicPage(p => Math.min(licTotalPages, p + 1))} disabled={licPage >= licTotalPages} style={btnGhost}>下一页</button>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
|
||||
{(showCreate || isEditing) && (
|
||||
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
|
||||
<div style={{ ...cardStyle, width: 640, maxHeight: '85vh', overflow: 'auto', padding: 28 }}>
|
||||
|
||||
<h2 style={{ margin: '0 0 20px' }}>{isEditing ? '编辑发行版' : '新建发行版'}</h2>
|
||||
|
||||
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>名称 *</label>
|
||||
|
||||
<input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" />
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>描述</label>
|
||||
|
||||
<textarea style={textareaStyle} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="发行版描述..." />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>品牌名称</label>
|
||||
|
||||
<input style={inputStyle} value={form.brand_name} onChange={e => setForm({ ...form, brand_name: e.target.value })} placeholder="例如: MyAI" />
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>品牌主色</label>
|
||||
|
||||
<input type="color" style={{ ...inputStyle, height: 38, padding: 4, width: 60 }} value={form.brand_color} onChange={e => setForm({ ...form, brand_color: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>系统提示词</label>
|
||||
|
||||
<textarea style={textareaStyle} value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} placeholder="You are a helpful AI assistant..." />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>LLM 提供商</label>
|
||||
|
||||
<select style={inputStyle} value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
|
||||
|
||||
<option value="siliconflow">SiliconFlow</option>
|
||||
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
|
||||
<option value="openai">OpenAI</option>
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>默认模型</label>
|
||||
|
||||
<input style={inputStyle} value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>版本号</label>
|
||||
|
||||
<input style={inputStyle} value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
||||
<label style={labelStyle}>功能特性</label>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
|
||||
|
||||
<button key={key}
|
||||
|
||||
onClick={() => toggleFeature(key)}
|
||||
|
||||
style={{
|
||||
|
||||
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
|
||||
|
||||
background: form.features.includes(key) ? '#6C63FF' : 'transparent',
|
||||
|
||||
color: form.features.includes(key) ? '#fff' : dark.sub,
|
||||
|
||||
border: form.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
|
||||
|
||||
}}>
|
||||
|
||||
{label}
|
||||
|
||||
</button>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
|
||||
<button onClick={() => { setShowCreate(false); setEditDistro(null); }} style={btnGhost}>取消</button>
|
||||
|
||||
<button onClick={isEditing ? handleUpdate : handleCreate} style={btnPrimary}>{isEditing ? '保存修改' : '创建'}</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* License Modal */}
|
||||
|
||||
{showLicense && (
|
||||
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
|
||||
<div style={{ ...cardStyle, width: 500, padding: 28 }}>
|
||||
|
||||
<h2 style={{ margin: '0 0 20px' }}>生成许可证</h2>
|
||||
|
||||
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>发行版 *</label>
|
||||
|
||||
<select style={inputStyle} value={selectedDistro} onChange={e => setSelectedDistro(e.target.value)}>
|
||||
|
||||
<option value="">-- 选择发行版 --</option>
|
||||
|
||||
{distributions.map(d => (
|
||||
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>组织名称 *</label>
|
||||
|
||||
<input style={inputStyle} value={licForm.org_name} onChange={e => setLicForm({ ...licForm, org_name: e.target.value })} placeholder="例如: Acme Corp" />
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>被授权邮箱</label>
|
||||
|
||||
<input style={inputStyle} value={licForm.licensee_email} onChange={e => setLicForm({ ...licForm, licensee_email: e.target.value })} placeholder="admin@example.com" />
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>最大用户数</label>
|
||||
|
||||
<input type="number" style={inputStyle} value={licForm.max_users} onChange={e => setLicForm({ ...licForm, max_users: Number(e.target.value) })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<label style={labelStyle}>最大执行次数</label>
|
||||
|
||||
<input type="number" style={inputStyle} value={licForm.max_executions} onChange={e => setLicForm({ ...licForm, max_executions: Number(e.target.value) })} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
|
||||
<label style={labelStyle}>过期日期 (留空为永久)</label>
|
||||
|
||||
<input type="date" style={inputStyle} value={licForm.expiry_date} onChange={e => setLicForm({ ...licForm, expiry_date: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
||||
<label style={labelStyle}>授权功能</label>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
|
||||
|
||||
<button key={key}
|
||||
|
||||
onClick={() => toggleLicFeature(key)}
|
||||
|
||||
style={{
|
||||
|
||||
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
|
||||
|
||||
background: licForm.features.includes(key) ? '#6C63FF' : 'transparent',
|
||||
|
||||
color: licForm.features.includes(key) ? '#fff' : dark.sub,
|
||||
|
||||
border: licForm.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
|
||||
|
||||
}}>
|
||||
|
||||
{label}
|
||||
|
||||
</button>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
|
||||
<button onClick={() => setShowLicense(false)} style={btnGhost}>取消</button>
|
||||
|
||||
<button onClick={handleGenerateLicense} style={btnPrimary}>生成</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,538 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface KnowledgeBaseItem {
|
||||
id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
owner_name?: string;
|
||||
description?: string;
|
||||
directory_path: string;
|
||||
document_count: number;
|
||||
knowledge_graph_count: number;
|
||||
is_official: boolean;
|
||||
status: string;
|
||||
external_service_id?: string;
|
||||
external_id?: string;
|
||||
external_sync_status: string;
|
||||
external_config?: any;
|
||||
total_storage_bytes?: number;
|
||||
total_storage_mb?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
doc_stats?: Record<string, { count: number; size: number }>;
|
||||
graph_stats?: { nodes: number; edges: number };
|
||||
}
|
||||
|
||||
interface ExternalService {
|
||||
id: string; name: string; service_type: string; api_url: string;
|
||||
api_key?: string; config?: any; is_enabled: boolean; status: string;
|
||||
created_at?: string; updated_at?: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_knowledge_bases: number; total_documents: number;
|
||||
total_storage_bytes: number; total_storage_display: string;
|
||||
official_kb: number; user_kb: number; active_kb: number;
|
||||
kb_with_graph: number; external_kb: number; recent_kb_7d: number;
|
||||
total_graph_nodes: number; total_graph_edges: number;
|
||||
doc_status_distribution: Record<string, number>;
|
||||
file_type_distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
};
|
||||
|
||||
const Knowledge: React.FC = () => {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterOfficial, setFilterOfficial] = useState('');
|
||||
|
||||
// External Services
|
||||
const [extServices, setExtServices] = useState<ExternalService[]>([]);
|
||||
const [showExtModal, setShowExtModal] = useState(false);
|
||||
const [extForm, setExtForm] = useState({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' });
|
||||
|
||||
// KB Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
|
||||
const [kbForm, setKBForm] = useState({ name: '', description: '', is_official: false });
|
||||
|
||||
// Detail drawer
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedKB, setSelectedKB] = useState<KnowledgeBaseItem | null>(null);
|
||||
const [detailDocs, setDetailDocs] = useState<any[]>([]);
|
||||
const [detailDocTotal, setDetailDocTotal] = useState(0);
|
||||
|
||||
// Document preview
|
||||
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
|
||||
const [previewContent, setPreviewContent] = useState<string>('');
|
||||
|
||||
// File upload
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats(); fetchKBs(); fetchExtServices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchKBs(); }, [page, keyword, filterStatus, filterOfficial]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try { const res = await adminAPI.getAdminKnowledgeStats(); if (res.code === 0) setStats(res.data); } catch (e) {}
|
||||
};
|
||||
|
||||
const fetchKBs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterOfficial) params.is_official = filterOfficial === 'true';
|
||||
const res = await adminAPI.getAdminKnowledge(params);
|
||||
if (res.code === 0) {
|
||||
setKnowledgeBases(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} catch (e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchExtServices = async () => {
|
||||
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
|
||||
};
|
||||
|
||||
const handleCreateKB = async () => {
|
||||
try {
|
||||
const res = await adminAPI.createAdminKnowledge(kbForm);
|
||||
if (res.code === 0) { setShowModal(false); setKBForm({ name: '', description: '', is_official: false }); fetchKBs(); fetchStats(); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleUpdateKB = async () => {
|
||||
if (!editingKB) return;
|
||||
try {
|
||||
const res = await adminAPI.updateAdminKnowledge(editingKB.id, kbForm);
|
||||
if (res.code === 0) { setShowModal(false); fetchKBs(); fetchStats(); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleDeleteKB = async (id: string) => {
|
||||
if (!confirm('确认删除该知识库及其所有文档?此操作不可恢复!')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledge(id); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleToggleKB = async (id: string, isActive: boolean) => {
|
||||
try { const res = await adminAPI.toggleAdminKnowledge(id, isActive); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (kb: KnowledgeBaseItem) => {
|
||||
setSelectedKB(kb); setShowDetail(true);
|
||||
try {
|
||||
const res = await adminAPI.getAdminKnowledgeDetail(kb.id);
|
||||
if (res.code === 0) setSelectedKB(res.data);
|
||||
const docRes = await adminAPI.getAdminKnowledgeDocs(kb.id, { page: 1, page_size: 50 });
|
||||
if (docRes.code === 0) { setDetailDocs(docRes.data.items || []); setDetailDocTotal(docRes.data.total || 0); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// Document upload
|
||||
const handleFileUpload = async (kbId: string, file: File) => {
|
||||
setUploading(true);
|
||||
setUploadMsg(`正在上传 ${file.name}...`);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Use raw fetch since adminAPI may not support FormData
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
setUploadMsg(`✅ ${file.name} 上传成功!`);
|
||||
handleViewDetail(selectedKB!);
|
||||
fetchStats();
|
||||
} else {
|
||||
setUploadMsg(`❌ 上传失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setUploadMsg(`❌ 上传失败: ${e.message || '网络错误'}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setTimeout(() => setUploadMsg(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Document preview
|
||||
const handlePreviewDoc = async (kbId: string, doc: any) => {
|
||||
setPreviewDoc(doc);
|
||||
setPreviewContent('加载中...');
|
||||
try {
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/${doc.id}/content`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
setPreviewContent(data.data?.content || data.data?.text || '(文档内容为空)');
|
||||
} else {
|
||||
setPreviewContent(data.message || '无法加载文档内容');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setPreviewContent(`加载失败: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (kbId: string, docId: string) => {
|
||||
if (!confirm('确认删除该文档?')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId); if (res.code === 0) { handleViewDetail(selectedKB!); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleCreateExtService = async () => {
|
||||
try { const res = await adminAPI.createAdminKnowledgeExtService(extForm); if (res.code === 0) { setShowExtModal(false); setExtForm({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' }); fetchExtServices(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleDeleteExtService = async (id: string) => {
|
||||
if (!confirm('确认删除该外部知识库服务配置?')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeExtService(id); if (res.code === 0) fetchExtServices(); } catch (e) {}
|
||||
};
|
||||
|
||||
const handleCheckExtStatus = async (id: string) => {
|
||||
try { const res = await adminAPI.checkAdminKnowledgeExtStatus(id); if (res.code === 0) { alert(`连接状态: ${res.data.connected ? '已连接' : '未连接'}\n详情: ${res.data.detail || '无'}`); fetchExtServices(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
active: { color: '#16A34A', text: '活跃' }, inactive: { color: '#6B7280', text: '禁用' },
|
||||
archived: { color: '#F59E0B', text: '归档' }, published: { color: '#16A34A', text: '已发布' },
|
||||
pending: { color: '#F59E0B', text: '待审核' }, completed: { color: '#16A34A', text: '完成' },
|
||||
processing: { color: '#3B82F6', text: '处理中' }, failed: { color: '#DC2626', text: '失败' },
|
||||
uploading: { color: '#3B82F6', text: '上传中' },
|
||||
};
|
||||
const s = map[status] || { color: '#6B7280', text: status };
|
||||
return <span style={{ fontSize: 12, padding: '2px 8px', background: s.color + '18', color: s.color, borderRadius: 4, fontWeight: 500 }}>{s.text}</span>;
|
||||
};
|
||||
|
||||
const syncStatusBadge = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
none: { color: '#9CA3AF', text: '未同步' }, syncing: { color: '#3B82F6', text: '同步中' },
|
||||
synced: { color: '#16A34A', text: '已同步' }, error: { color: '#DC2626', text: '同步错误' },
|
||||
};
|
||||
const s = map[status] || { color: '#9CA3AF', text: status };
|
||||
return <span style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</span>;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const statCardDefs = stats ? [
|
||||
{ label: '知识库总数', value: stats.total_knowledge_bases, color: '#6C63FF' },
|
||||
{ label: '文档总数', value: stats.total_documents, color: '#219B5E' },
|
||||
{ label: '存储占用', value: stats.total_storage_display, color: '#E8922C' },
|
||||
{ label: '官方知识库', value: stats.official_kb, color: '#8B5CF6' },
|
||||
{ label: '含图谱KB', value: stats.kb_with_graph, color: '#3B82F6' },
|
||||
{ label: '外部集成', value: stats.external_kb, color: '#EC4899' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<h1 className="admin-page-title">知识库管理</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="admin-stats" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
{statCardDefs.map((s, i) => (
|
||||
<div key={i} className="admin-stat-card" style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color, fontFamily: 'var(--font-mono)' }}>
|
||||
{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 4 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input type="text" placeholder="搜索知识库..." value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
style={inputStyle} />
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} style={selectStyle}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
<select value={filterOfficial} onChange={e => setFilterOfficial(e.target.value)} style={selectStyle}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="true">官方</option>
|
||||
<option value="false">用户</option>
|
||||
</select>
|
||||
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
|
||||
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
|
||||
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="admin-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th><th>来源</th><th>文档数</th><th>存储</th><th>状态</th><th>同步</th><th>创建时间</th><th style={{ textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40 }}>加载中...</td></tr>
|
||||
) : knowledgeBases.length === 0 ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40, color: '#9CA3AF' }}>暂无知识库数据</td></tr>
|
||||
) : knowledgeBases.map(kb => (
|
||||
<tr key={kb.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{kb.name}</div>
|
||||
{kb.description && <div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 2 }}>{kb.description.substring(0, 60)}</div>}
|
||||
</td>
|
||||
<td>{kb.is_official ? <span style={{ color: '#8B5CF6', fontWeight: 600 }}>官方</span> : <span style={{ color: '#6B7280' }}>{kb.owner_name || '用户'}</span>}</td>
|
||||
<td style={{ fontWeight: 600 }}>{kb.document_count}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{kb.total_storage_mb || 0} MB</td>
|
||||
<td>{statusBadge(kb.status)}</td>
|
||||
<td>{syncStatusBadge(kb.external_sync_status)}</td>
|
||||
<td style={{ fontSize: 12, color: '#9CA3AF' }}>{kb.created_at?.substring(0, 10)}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<button onClick={() => handleViewDetail(kb)} style={btnStyle('#3B82F6', 'small')}>详情</button>
|
||||
<button onClick={() => { setEditingKB(kb); setKBForm({ name: kb.name, description: kb.description || '', is_official: kb.is_official }); setShowModal(true); }} style={{ ...btnStyle('#F59E0B', 'small'), marginLeft: 4 }}>编辑</button>
|
||||
<button onClick={() => handleToggleKB(kb.id, kb.status !== 'active')} style={{ ...btnStyle(kb.status === 'active' ? '#DC2626' : '#16A34A', 'small'), marginLeft: 4 }}>{kb.status === 'active' ? '禁用' : '启用'}</button>
|
||||
<button onClick={() => handleDeleteKB(kb.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={btnStyle(page <= 1 ? '#D1D5DB' : '#6C63FF', 'small')}>上一页</button>
|
||||
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page}/{totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={btnStyle(page >= totalPages ? '#D1D5DB' : '#6C63FF', 'small')}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KB Modal */}
|
||||
{showModal && (
|
||||
<div style={overlayStyle} onClick={() => setShowModal(false)}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<h3>{editingKB ? '编辑知识库' : '创建知识库'}</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>名称</label>
|
||||
<input value={kbForm.name} onChange={e => setKBForm({ ...kbForm, name: e.target.value })} style={fullInput} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>描述</label>
|
||||
<textarea value={kbForm.description} onChange={e => setKBForm({ ...kbForm, description: e.target.value })} rows={3} style={fullInput} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={kbForm.is_official} onChange={e => setKBForm({ ...kbForm, is_official: e.target.checked })} />
|
||||
官方平台知识库
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button onClick={editingKB ? handleUpdateKB : handleCreateKB} style={btnStyle('#6C63FF')}>{editingKB ? '保存' : '创建'}</button>
|
||||
<button onClick={() => setShowModal(false)} style={btnStyle('#6B7280')}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{showDetail && selectedKB && (
|
||||
<div style={overlayStyle} onClick={() => setShowDetail(false)}>
|
||||
<div style={{ ...drawerStyle }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>{selectedKB.name}</h3>
|
||||
<button onClick={() => setShowDetail(false)} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#F9FAFB', borderRadius: 8, fontSize: 13 }}>
|
||||
<div>来源: {selectedKB.is_official ? '官方' : selectedKB.owner_name || '用户'}</div>
|
||||
{selectedKB.description && <div style={{ marginTop: 4 }}>描述: {selectedKB.description}</div>}
|
||||
<div style={{ marginTop: 4 }}>状态: {statusBadge(selectedKB.status)} | 文档: {selectedKB.document_count} | 存储: {selectedKB.total_storage_mb || 0} MB</div>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="file" ref={fileInputRef} style={{ display: 'none' }}
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(selectedKB.id, f); e.target.value = ''; }}
|
||||
accept=".pdf,.docx,.doc,.txt,.md,.csv,.json,.xml" />
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||
style={btnStyle(uploading ? '#D1D5DB' : '#219B5E')}>
|
||||
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
|
||||
{' '}{uploading ? '上传中...' : '上传文档'}
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>支持 PDF / DOCX / TXT / MD / CSV / JSON</span>
|
||||
</div>
|
||||
{uploadMsg && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: uploadMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: uploadMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
|
||||
{uploadMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph Stats */}
|
||||
{selectedKB.graph_stats && (selectedKB.graph_stats.nodes > 0 || selectedKB.graph_stats.edges > 0) && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#EEF2FF', borderRadius: 8 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>知识图谱</h4>
|
||||
<div style={{ fontSize: 13 }}>节点数: {selectedKB.graph_stats.nodes} | 边数: {selectedKB.graph_stats.edges}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Integration */}
|
||||
{selectedKB.external_service_id && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#F5F3FF', borderRadius: 8, fontSize: 13 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>外部集成</h4>
|
||||
<div>同步状态: {syncStatusBadge(selectedKB.external_sync_status)}</div>
|
||||
<div>外部ID: {selectedKB.external_id || '未关联'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>文档列表 ({detailDocTotal})</h4>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th style={{ textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailDocs.map(doc => (
|
||||
<tr key={doc.id}>
|
||||
<td style={{ fontSize: 12 }}>{doc.filename || doc.file_name}</td>
|
||||
<td style={{ fontSize: 11 }}><span style={{ padding: '1px 6px', background: '#F3F4F6', borderRadius: 3 }}>{doc.file_type || '-'}</span></td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatSize(doc.file_size || 0)}</td>
|
||||
<td>{statusBadge(doc.status)}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<button onClick={() => handlePreviewDoc(selectedKB.id, doc)} style={btnStyle('#3B82F6', 'small')}>预览</button>
|
||||
<button onClick={() => handleDeleteDoc(selectedKB.id, doc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{detailDocs.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#9CA3AF' }}>暂无文档,请上传</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Preview Modal */}
|
||||
{previewDoc && (
|
||||
<div style={overlayStyle} onClick={() => { setPreviewDoc(null); setPreviewContent(''); }}>
|
||||
<div style={{ ...drawerStyle, maxWidth: '80vw' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>{previewDoc.filename || previewDoc.file_name}</h3>
|
||||
<button onClick={() => { setPreviewDoc(null); setPreviewContent(''); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6B7280', marginBottom: 12 }}>
|
||||
类型: {previewDoc.file_type || '-'} | 大小: {formatSize(previewDoc.file_size || 0)}
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: '60vh', overflowY: 'auto', background: '#F9FAFB',
|
||||
borderRadius: 8, padding: 16, fontSize: 13, lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
fontFamily: 'var(--font-mono)', color: '#374151',
|
||||
border: '1px solid #E5E7EB',
|
||||
}}>
|
||||
{previewContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Service Modal */}
|
||||
{showExtModal && (
|
||||
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
|
||||
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
|
||||
<h3>外部知识库服务配置</h3>
|
||||
{extServices.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{extServices.map(svc => (
|
||||
<tr key={svc.id}>
|
||||
<td>{svc.name}</td>
|
||||
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>{svc.service_type === 'fastgpt' ? 'FastGPT' : svc.service_type === 'dify' ? 'Dify' : 'RAGFlow'}</span></td>
|
||||
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td>
|
||||
<td>{statusBadge(svc.status)}</td>
|
||||
<td>
|
||||
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
|
||||
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>添加新服务</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 FastGPT" style={fullInput} /></div>
|
||||
<div><label style={labelStyle}>类型</label><select value={extForm.service_type} onChange={e => setExtForm({ ...extForm, service_type: e.target.value })} style={fullInput}><option value="fastgpt">FastGPT</option><option value="dify">Dify</option><option value="ragflow">RAGFlow</option></select></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://your-instance.com/api/v1" style={fullInput} /></div>
|
||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="Bearer Token 或 API Key" style={fullInput} /></div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>添加服务</button>
|
||||
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Shared styles
|
||||
const inputStyle: React.CSSProperties = { padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, width: 200, boxSizing: 'border-box' };
|
||||
const selectStyle: React.CSSProperties = { padding: '8px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' };
|
||||
const fullInput: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' };
|
||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' };
|
||||
const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const modalStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 500, maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto' };
|
||||
const drawerStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 560, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' };
|
||||
|
||||
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
||||
background: color, color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
fontSize: size === 'small' ? 12 : 14, padding: size === 'small' ? '4px 10px' : '8px 16px',
|
||||
fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export default Knowledge;
|
||||
@@ -0,0 +1,83 @@
|
||||
/* ===== Logs Page Styles ===== */
|
||||
|
||||
.log-stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.log-stat-item {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-stat-value {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.log-stat-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.log-filter-bar {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.log-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.log-filter-item {
|
||||
flex: 0 0 auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.log-filter-item label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-filter-input {
|
||||
height: 34px !important;
|
||||
padding: 4px 10px !important;
|
||||
font-size: var(--text-xs) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.log-pagination-info {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.log-pagination-btns {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import './Logs.css';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
type TabKey = 'operation' | 'token' | 'payment' | 'audit' | 'api_stats' | 'slow';
|
||||
|
||||
interface LogStats {
|
||||
today: {
|
||||
api_calls: number;
|
||||
errors: number;
|
||||
error_rate: number;
|
||||
operation_logs: number;
|
||||
audit_logs: number;
|
||||
active_users: number;
|
||||
total_tokens: number;
|
||||
slow_requests: number;
|
||||
};
|
||||
total: {
|
||||
users: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
keyword: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
action: string;
|
||||
status: string;
|
||||
adminName: string;
|
||||
}
|
||||
|
||||
const defaultFilters: FilterState = {
|
||||
keyword: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
userId: '',
|
||||
username: '',
|
||||
action: '',
|
||||
status: '',
|
||||
adminName: '',
|
||||
};
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('operation');
|
||||
const [filters, setFilters] = useState<FilterState>(defaultFilters);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Data states
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<LogStats | null>(null);
|
||||
|
||||
// Action types for filter dropdowns
|
||||
const [actionTypes, setActionTypes] = useState<{
|
||||
operation_actions: string[];
|
||||
audit_actions: string[];
|
||||
audit_target_types: string[];
|
||||
}>({ operation_actions: [], audit_actions: [], audit_target_types: [] });
|
||||
|
||||
// Fetch action types on mount
|
||||
useEffect(() => {
|
||||
adminAPI.getActionTypes().then(res => {
|
||||
if (res.data) setActionTypes(res.data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Fetch stats
|
||||
useEffect(() => {
|
||||
adminAPI.getLogStats().then(res => {
|
||||
if (res.data) setStats(res.data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Fetch data when tab or filters change
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (filters.keyword) params.keyword = filters.keyword;
|
||||
if (filters.startDate) params.start_date = filters.startDate;
|
||||
if (filters.endDate) params.end_date = filters.endDate;
|
||||
if (filters.userId) params.user_id = filters.userId;
|
||||
if (filters.username) params.username = filters.username;
|
||||
if (filters.action) params.action = filters.action;
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.adminName) params.admin_name = filters.adminName;
|
||||
|
||||
let res: any;
|
||||
switch (activeTab) {
|
||||
case 'operation':
|
||||
res = await adminAPI.getOperationLogs(params);
|
||||
break;
|
||||
case 'token':
|
||||
res = await adminAPI.getTokenLogs(params);
|
||||
break;
|
||||
case 'payment':
|
||||
res = await adminAPI.getPaymentLogs(params);
|
||||
break;
|
||||
case 'audit':
|
||||
res = await adminAPI.getAuditLogs(params);
|
||||
break;
|
||||
case 'api_stats':
|
||||
res = await adminAPI.getApiStats(params);
|
||||
break;
|
||||
case 'slow':
|
||||
res = await adminAPI.getSlowRequests(params);
|
||||
break;
|
||||
}
|
||||
if (res.data) {
|
||||
setData(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, page, pageSize, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'api_stats' || activeTab === 'slow') {
|
||||
fetchData();
|
||||
}
|
||||
}, [activeTab, page, fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'operation' || activeTab === 'token' || activeTab === 'payment' || activeTab === 'audit') {
|
||||
fetchData();
|
||||
}
|
||||
}, [activeTab, page, filters, fetchData]);
|
||||
|
||||
// Reset filters when tab changes
|
||||
useEffect(() => {
|
||||
setFilters(defaultFilters);
|
||||
setPage(1);
|
||||
setShowFilters(false);
|
||||
}, [activeTab]);
|
||||
|
||||
// Export handler
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filters.keyword) params.keyword = filters.keyword;
|
||||
if (filters.startDate) params.start_date = filters.startDate;
|
||||
if (filters.endDate) params.end_date = filters.endDate;
|
||||
if (filters.userId) params.user_id = filters.userId;
|
||||
if (filters.action) params.action = filters.action;
|
||||
if (filters.adminName) params.admin_name = filters.adminName;
|
||||
|
||||
let blob: Blob;
|
||||
let filename: string;
|
||||
const ts = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
|
||||
switch (activeTab) {
|
||||
case 'operation': {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const resp = await fetch(`/app/api/admin/logs/export/operations?${qs}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
|
||||
});
|
||||
blob = await resp.blob();
|
||||
filename = `operations_${ts}.csv`;
|
||||
break;
|
||||
}
|
||||
case 'token': {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const resp = await fetch(`/app/api/admin/logs/export/token-logs?${qs}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
|
||||
});
|
||||
blob = await resp.blob();
|
||||
filename = `token_logs_${ts}.csv`;
|
||||
break;
|
||||
}
|
||||
case 'audit': {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const resp = await fetch(`/app/api/admin/logs/export/audit-logs?${qs}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
|
||||
});
|
||||
blob = await resp.blob();
|
||||
filename = `audit_logs_${ts}.csv`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
alert('当前Tab不支持导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'operation', label: '操作日志' },
|
||||
{ key: 'token', label: '调用日志' },
|
||||
{ key: 'payment', label: '付费日志' },
|
||||
{ key: 'audit', label: '审计日志' },
|
||||
{ key: 'api_stats', label: 'API统计' },
|
||||
{ key: 'slow', label: '慢请求' },
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Render filter bar
|
||||
const renderFilters = () => {
|
||||
if (!showFilters) return null;
|
||||
|
||||
return (
|
||||
<div className="log-filter-bar">
|
||||
<div className="log-filter-row">
|
||||
{(['operation', 'token', 'payment'] as TabKey[]).includes(activeTab) && (
|
||||
<>
|
||||
<div className="log-filter-item">
|
||||
<label>关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索目标/详情/模型名称"
|
||||
value={filters.keyword}
|
||||
onChange={e => { setFilters(f => ({ ...f, keyword: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="log-filter-item">
|
||||
<label>用户ID</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户ID"
|
||||
value={filters.userId}
|
||||
onChange={e => { setFilters(f => ({ ...f, userId: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'operation' && (
|
||||
<div className="log-filter-item">
|
||||
<label>操作类型</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{actionTypes.operation_actions.map(a => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'audit' && (
|
||||
<>
|
||||
<div className="log-filter-item">
|
||||
<label>管理员</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="管理员名称"
|
||||
value={filters.adminName}
|
||||
onChange={e => { setFilters(f => ({ ...f, adminName: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="log-filter-item">
|
||||
<label>操作类型</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{actionTypes.audit_actions.map(a => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="log-filter-item">
|
||||
<label>开始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={e => { setFilters(f => ({ ...f, startDate: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="log-filter-item">
|
||||
<label>结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={e => { setFilters(f => ({ ...f, endDate: e.target.value })); setPage(1); }}
|
||||
className="admin-form-input log-filter-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="log-filter-item" style={{ alignSelf: 'flex-end' }}>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
onClick={() => { setFilters(defaultFilters); setPage(1); }}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render stats overview
|
||||
const renderStats = () => {
|
||||
if (!stats) return null;
|
||||
const s = stats.today;
|
||||
|
||||
return (
|
||||
<div className="log-stats-overview">
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value">{s.api_calls.toLocaleString()}</div>
|
||||
<div className="log-stat-label">今日API调用</div>
|
||||
</div>
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value" style={{ color: s.error_rate > 10 ? 'var(--color-error)' : 'var(--color-success)' }}>
|
||||
{s.errors}
|
||||
</div>
|
||||
<div className="log-stat-label">今日错误 ({s.error_rate}%)</div>
|
||||
</div>
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value">{s.active_users}</div>
|
||||
<div className="log-stat-label">今日活跃用户</div>
|
||||
</div>
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value">{s.total_tokens.toLocaleString()}</div>
|
||||
<div className="log-stat-label">今日Token消耗</div>
|
||||
</div>
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value">{s.operation_logs}</div>
|
||||
<div className="log-stat-label">今日操作日志</div>
|
||||
</div>
|
||||
<div className="log-stat-item">
|
||||
<div className="log-stat-value" style={{ color: s.slow_requests > 0 ? 'var(--color-warning)' : 'var(--color-success)' }}>
|
||||
{s.slow_requests}
|
||||
</div>
|
||||
<div className="log-stat-label">慢请求数</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render pagination
|
||||
const renderPagination = () => (
|
||||
<div className="log-pagination">
|
||||
<span className="log-pagination-info">
|
||||
共 {total} 条记录,第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="log-pagination-btns">
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render operation log table
|
||||
const renderOperationTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>详情</th>
|
||||
<th>IP</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((log: any) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontWeight: 500 }}>{log.username}</td>
|
||||
<td><span className="admin-badge badge-info">{log.action}</span></td>
|
||||
<td>{log.target}</td>
|
||||
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render token log table
|
||||
const renderTokenTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>模型</th>
|
||||
<th>应用</th>
|
||||
<th>输入Token</th>
|
||||
<th>输出Token</th>
|
||||
<th>总Token</th>
|
||||
<th>费用</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((log: any) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontWeight: 500 }}>{log.user_id}</td>
|
||||
<td>{log.model_name}</td>
|
||||
<td>{log.app_name ? <span className="admin-badge badge-neutral">{log.app_name}</span> : '-'}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.prompt_tokens.toLocaleString()}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.completion_tokens.toLocaleString()}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{log.total_tokens.toLocaleString()}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-error)' }}>{Number(log.cost).toFixed(4)} 元</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render payment table
|
||||
const renderPaymentTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>项目</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((o: any) => (
|
||||
<tr key={o.id}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{o.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{o.user_id}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${o.order_type === 'membership' ? 'badge-info' : o.order_type === 'app' ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{o.order_type === 'membership' ? '会员' : o.order_type === 'app' ? '应用' : '服务'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{o.item_name}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{Number(o.amount).toFixed(2)} 元</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${o.status === 'paid' ? 'badge-success' : o.status === 'pending' ? 'badge-warning' : 'badge-error'}`}>
|
||||
{o.status === 'paid' ? '已支付' : o.status === 'pending' ? '待支付' : o.status === 'refunded' ? '已退款' : o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{o.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render audit table
|
||||
const renderAuditTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>管理员</th>
|
||||
<th>操作</th>
|
||||
<th>目标类型</th>
|
||||
<th>详情</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((log: any) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
|
||||
<td><span className="admin-badge badge-warning">{log.action}</span></td>
|
||||
<td><span className="admin-badge badge-neutral">{log.target_type}</span></td>
|
||||
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render API stats table
|
||||
const renderApiStatsTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>端点</th>
|
||||
<th>方法</th>
|
||||
<th>调用次数</th>
|
||||
<th>平均响应时间</th>
|
||||
<th>错误数</th>
|
||||
<th>错误率</th>
|
||||
<th>最近调用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((s: any) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{s.endpoint}</td>
|
||||
<td><span className="admin-badge badge-info">{s.method}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{s.call_count.toLocaleString()}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
<span style={{ color: s.avg_response_time > 3 ? 'var(--color-error)' : s.avg_response_time > 1 ? 'var(--color-warning)' : 'var(--color-success)' }}>
|
||||
{(s.avg_response_time * 1000).toFixed(0)}ms
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: s.error_count > 0 ? 'var(--color-error)' : 'inherit' }}>{s.error_count}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${s.error_rate > 10 ? 'badge-error' : s.error_rate > 5 ? 'badge-warning' : 'badge-success'}`}>
|
||||
{s.error_rate}%
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{s.last_called_at || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无API调用统计数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render slow request table
|
||||
const renderSlowTable = () => (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>方法</th>
|
||||
<th>端点</th>
|
||||
<th>状态码</th>
|
||||
<th>响应时间</th>
|
||||
<th>阈值</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((log: any) => (
|
||||
<tr key={log.id}>
|
||||
<td><span className="admin-badge badge-info">{log.method}</span></td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.endpoint}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${log.status_code >= 400 ? 'badge-error' : 'badge-success'}`}>
|
||||
{log.status_code}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--color-error)' }}>
|
||||
{(log.response_time * 1000).toFixed(0)}ms
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
|
||||
{(log.threshold * 1000).toFixed(0)}ms
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无慢请求记录</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>日志与风控管理</h1>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => setShowFilters(!showFilters)}>
|
||||
<SVGIcon><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></SVGIcon>
|
||||
筛选
|
||||
</button>
|
||||
{['operation', 'token', 'audit'].includes(activeTab) && (
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={handleExport}>
|
||||
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
|
||||
导出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{renderStats()}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: 'var(--space-sm) var(--space-lg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-sm)', fontWeight: 600,
|
||||
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
|
||||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{renderFilters()}
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">
|
||||
{tabs.find(t => t.key === activeTab)?.label}
|
||||
{total > 0 && <span style={{ fontWeight: 400, fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)' }}>({total} 条记录)</span>}
|
||||
</h3>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={fetchData}>
|
||||
<SVGIcon size={14}><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></SVGIcon>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-disabled)' }}>
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'operation' && renderOperationTable()}
|
||||
{activeTab === 'token' && renderTokenTable()}
|
||||
{activeTab === 'payment' && renderPaymentTable()}
|
||||
{activeTab === 'audit' && renderAuditTable()}
|
||||
{activeTab === 'api_stats' && renderApiStatsTable()}
|
||||
{activeTab === 'slow' && renderSlowTable()}
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -0,0 +1,963 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import McpMarketTab from './McpMarketTab';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
interface MarketAppItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
category: string;
|
||||
tags: string | null;
|
||||
source: string;
|
||||
publisher_name: string | null;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
price: number;
|
||||
is_member_free: boolean;
|
||||
featured: boolean;
|
||||
featured_order: number;
|
||||
status: string;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
install_count: number;
|
||||
version: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
tutorial?: string;
|
||||
skill_content?: string;
|
||||
}
|
||||
|
||||
interface OverviewData {
|
||||
total_apps: number;
|
||||
published: number;
|
||||
pending: number;
|
||||
removed: number;
|
||||
total_installs: number;
|
||||
total_purchases: number;
|
||||
total_revenue: number;
|
||||
total_reviews: number;
|
||||
overall_rating: number;
|
||||
by_type: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
trend_7d: Array<{ date: string; new_apps: number; new_installs: number }>;
|
||||
}
|
||||
|
||||
interface CouponItem {
|
||||
id: string;
|
||||
code: string;
|
||||
type: string;
|
||||
value: number;
|
||||
min_amount: number;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
total_count: number;
|
||||
used_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ReviewItem {
|
||||
id: string;
|
||||
rating: number;
|
||||
content: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
app_name: string;
|
||||
app_icon: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
coding: '编程开发', assistant: '智能助手', tools: '效率工具',
|
||||
writing: '内容创作', education: '教育培训', marketing: '营销推广',
|
||||
data: '数据分析', design: '设计创意', business: '企业管理', other: '其他',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
skill: 'Skills工具', agent: '智能Agent', mcp: 'MCP协议',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待审核', published: '已发布', removed: '已下架',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'var(--color-warning)', published: 'var(--color-success)', removed: 'var(--color-danger)',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Icon helpers
|
||||
// ============================================================
|
||||
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
|
||||
const icons: Record<string, string> = {
|
||||
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', store: 'M3 9l1-5h16l1 5M4 9v11h16V9M9 14h6',
|
||||
check: 'M20 6L9 17l-5-5', close: 'M18 6L6 18M6 6l12 12', edit: 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
|
||||
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
|
||||
trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
|
||||
tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z',
|
||||
plus: 'M12 5v14M5 12h14', search: 'M11 4a7 7 0 100 14 7 7 0 000-14zM21 21l-4.35-4.35',
|
||||
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
|
||||
refresh: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15',
|
||||
trending: 'M23 6l-9.5 9.5-5-5L1 18', eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z',
|
||||
};
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<path d={icons[name] || ''} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main Component
|
||||
// ============================================================
|
||||
const Market: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews' | 'mcp'>('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Overview data
|
||||
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||||
const [topApps, setTopApps] = useState<MarketAppItem[]>([]);
|
||||
const [topMetric, setTopMetric] = useState<string>('installs');
|
||||
|
||||
// Apps list
|
||||
const [apps, setApps] = useState<MarketAppItem[]>([]);
|
||||
const [appsTotal, setAppsTotal] = useState(0);
|
||||
const [appsPage, setAppsPage] = useState(1);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterKeyword, setFilterKeyword] = useState('');
|
||||
|
||||
// Pending
|
||||
const [pendingApps, setPendingApps] = useState<MarketAppItem[]>([]);
|
||||
const [pendingTotal, setPendingTotal] = useState(0);
|
||||
|
||||
// Coupons
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [couponModal, setCouponModal] = useState(false);
|
||||
const [editingCoupon, setEditingCoupon] = useState<CouponItem | null>(null);
|
||||
|
||||
// Reviews
|
||||
const [reviews, setReviews] = useState<ReviewItem[]>([]);
|
||||
|
||||
// Edit modal
|
||||
const [editApp, setEditApp] = useState<MarketAppItem | null>(null);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getMarketOverview();
|
||||
if (res?.code === 0) setOverview(res.data);
|
||||
const topRes = await adminAPI.getTopApps(topMetric, 10);
|
||||
if (topRes?.code === 0) setTopApps(topRes.data || []);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, [topMetric]);
|
||||
|
||||
const loadApps = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (filterType) params.set('app_type', filterType);
|
||||
if (filterCategory) params.set('category', filterCategory);
|
||||
if (filterKeyword) params.set('keyword', filterKeyword);
|
||||
params.set('page', String(appsPage));
|
||||
params.set('page_size', '20');
|
||||
const res = await adminAPI.getAdminMarketApps(params.toString());
|
||||
if (res?.code === 0) {
|
||||
const d = res.data as any;
|
||||
setApps(d?.items || []);
|
||||
setAppsTotal(d?.total || 0);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, [filterStatus, filterType, filterCategory, filterKeyword, appsPage]);
|
||||
|
||||
const loadPending = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getPendingApps();
|
||||
if (res?.code === 0) {
|
||||
const d = res.data as any;
|
||||
setPendingApps(d?.items || d || []);
|
||||
setPendingTotal(d?.total || (Array.isArray(d) ? d.length : 0));
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadCoupons = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getCoupons();
|
||||
if (res?.code === 0) { const d = res.data as any; setCoupons(d?.items || d || []); }
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadReviews = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getRecentReviews();
|
||||
if (res?.code === 0) setReviews(res.data || []);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview') loadOverview();
|
||||
else if (activeTab === 'apps') loadApps();
|
||||
else if (activeTab === 'pending') loadPending();
|
||||
else if (activeTab === 'coupons') loadCoupons();
|
||||
else if (activeTab === 'reviews') loadReviews();
|
||||
}, [activeTab, loadOverview, loadApps, loadPending, loadCoupons, loadReviews]);
|
||||
|
||||
// Actions
|
||||
const handleApprove = async (id: string) => {
|
||||
if (!confirm('确认审核通过?')) return;
|
||||
try {
|
||||
await adminAPI.approveApp(id);
|
||||
loadPending();
|
||||
loadOverview();
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
const reason = prompt('拒绝原因:', '');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
await adminAPI.rejectApp(id + '?reason=' + encodeURIComponent(reason));
|
||||
loadPending();
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const handleToggleFeatured = async (app: MarketAppItem) => {
|
||||
try {
|
||||
await adminAPI.setFeaturedApp(app.id, !app.featured, app.featured ? 0 : 1);
|
||||
loadApps();
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const handleDeleteApp = async (id: string, name: string) => {
|
||||
if (!confirm(`确认删除应用「${name}」?此操作不可恢复!`)) return;
|
||||
try {
|
||||
await adminAPI.deleteMarketApp(id);
|
||||
loadApps();
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const handleDeleteCoupon = async (id: string) => {
|
||||
if (!confirm('确认删除此优惠券?')) return;
|
||||
try {
|
||||
await adminAPI.deleteCoupon(id);
|
||||
loadCoupons();
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: '数据总览', icon: 'chart' },
|
||||
{ key: 'apps', label: '应用管理', icon: 'store' },
|
||||
{ key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' },
|
||||
{ key: 'coupons', label: '优惠券', icon: 'tag' },
|
||||
{ key: 'reviews', label: '用户评价', icon: 'star' },
|
||||
{ key: 'mcp', label: 'MCP工具', icon: 'tool' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>
|
||||
市场管理
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>
|
||||
管理应用市场、审核提交、分析数据、配置优惠券
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
|
||||
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Icon name={tab.icon} size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!loading && activeTab === 'overview' && overview && (
|
||||
<MarketOverview overview={overview} topApps={topApps} topMetric={topMetric} onMetricChange={setTopMetric} />
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'apps' && (
|
||||
<AppsManagement
|
||||
apps={apps} total={appsTotal} page={appsPage} onPageChange={setAppsPage}
|
||||
filterStatus={filterStatus} setFilterStatus={setFilterStatus}
|
||||
filterType={filterType} setFilterType={setFilterType}
|
||||
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
|
||||
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
|
||||
onEdit={setEditApp} onToggleFeatured={handleToggleFeatured} onDelete={handleDeleteApp}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'pending' && (
|
||||
<PendingApps apps={pendingApps} onApprove={handleApprove} onReject={handleReject} />
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'coupons' && (
|
||||
<CouponsManagement
|
||||
coupons={coupons} onAdd={() => { setEditingCoupon(null); setCouponModal(true); }}
|
||||
onEdit={(c) => { setEditingCoupon(c); setCouponModal(true); }}
|
||||
onDelete={handleDeleteCoupon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'reviews' && (
|
||||
<ReviewsList reviews={reviews} />
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'mcp' && (
|
||||
<McpMarketTab />
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editApp && (
|
||||
<EditAppModal app={editApp} onClose={() => setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} />
|
||||
)}
|
||||
|
||||
{/* Coupon Modal */}
|
||||
{couponModal && (
|
||||
<CouponModal coupon={editingCoupon} onClose={() => setCouponModal(false)} onSaved={() => { setCouponModal(false); loadCoupons(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Overview Component
|
||||
// ============================================================
|
||||
const MarketOverview: React.FC<{ overview: OverviewData; topApps: MarketAppItem[]; topMetric: string; onMetricChange: (v: string) => void }> = ({
|
||||
overview, topApps, topMetric, onMetricChange
|
||||
}) => {
|
||||
const stats = [
|
||||
{ label: '总应用数', value: overview.total_apps, sub: `${overview.published} 已发布`, color: 'var(--color-primary)' },
|
||||
{ label: '总安装量', value: overview.total_installs, sub: `${overview.pending} 待审核`, color: 'var(--color-success)' },
|
||||
{ label: '总收入', value: `¥${overview.total_revenue.toFixed(2)}`, sub: `${overview.total_purchases} 笔购买`, color: 'var(--color-warning)' },
|
||||
{ label: '平均评分', value: overview.overall_rating.toFixed(1), sub: `${overview.total_reviews} 条评价`, color: 'var(--color-danger)' },
|
||||
];
|
||||
|
||||
const maxTrend = Math.max(...overview.trend_7d.map(t => Math.max(t.new_apps, t.new_installs)), 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stat cards */}
|
||||
<div className="responsive-grid-4" style={{ gap: '16px', marginBottom: '24px' }}>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 7-day trend */}
|
||||
<div style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px',
|
||||
border: '1px solid var(--color-border)', marginBottom: '24px',
|
||||
}}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 20px', color: 'var(--color-text-primary)' }}>
|
||||
7日趋势
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '180px' }}>
|
||||
{overview.trend_7d.map((t, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'flex-end', height: '140px' }}>
|
||||
<div style={{
|
||||
width: '16px', height: `${(t.new_apps / maxTrend) * 130 + 4}px`,
|
||||
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0',
|
||||
minHeight: '4px', transition: 'height 0.3s',
|
||||
}} title={`新应用: ${t.new_apps}`} />
|
||||
<div style={{
|
||||
width: '16px', height: `${(t.new_installs / maxTrend) * 130 + 4}px`,
|
||||
background: 'var(--color-success)', borderRadius: '4px 4px 0 0',
|
||||
minHeight: '4px', transition: 'height 0.3s',
|
||||
}} title={`新安装: ${t.new_installs}`} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
|
||||
{t.date.slice(5)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '24px', marginTop: '16px', fontSize: '12px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ width: '12px', height: '12px', background: 'var(--color-primary)', borderRadius: '3px' }} />
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>新应用</span>
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ width: '12px', height: '12px', background: 'var(--color-success)', borderRadius: '3px' }} />
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>新安装</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category distribution + Top apps */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
||||
{/* Categories */}
|
||||
<div style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
|
||||
{Object.entries(overview.by_category).map(([cat, cnt]) => (
|
||||
<div key={cat} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{CATEGORY_LABELS[cat] || cat}</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600 }}>{cnt}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(overview.by_category).length === 0 && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top apps */}
|
||||
<div style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>热门应用</h3>
|
||||
<select
|
||||
value={topMetric}
|
||||
onChange={(e) => onMetricChange(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: '6px', border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg-primary)', color: 'var(--color-text-primary)', fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<option value="installs">按安装量</option>
|
||||
<option value="rating">按评分</option>
|
||||
<option value="revenue">按收入</option>
|
||||
<option value="reviews">按评价数</option>
|
||||
</select>
|
||||
</div>
|
||||
{topApps.map((app, i) => (
|
||||
<div key={app.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>
|
||||
#{i + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{app.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
|
||||
{TYPE_LABELS[app.type] || app.type} · {CATEGORY_LABELS[app.category] || app.category}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', fontSize: '13px', fontWeight: 600 }}>
|
||||
{topMetric === 'installs' && `${app.install_count}`}
|
||||
{topMetric === 'rating' && `${app.rating_avg.toFixed(1)}`}
|
||||
{topMetric === 'reviews' && `${app.review_count}`}
|
||||
{topMetric === 'revenue' && `¥${(app as any).revenue?.toFixed(0) || 0}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{topApps.length === 0 && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Apps Management Component
|
||||
// ============================================================
|
||||
const AppsManagement: React.FC<{
|
||||
apps: MarketAppItem[]; total: number; page: number; onPageChange: (p: number) => void;
|
||||
filterStatus: string; setFilterStatus: (v: string) => void;
|
||||
filterType: string; setFilterType: (v: string) => void;
|
||||
filterCategory: string; setFilterCategory: (v: string) => void;
|
||||
filterKeyword: string; setFilterKeyword: (v: string) => void;
|
||||
onEdit: (app: MarketAppItem) => void; onToggleFeatured: (app: MarketAppItem) => void; onDelete: (id: string, name: string) => void;
|
||||
}> = ({ apps, total, page, onPageChange, filterStatus, setFilterStatus, filterType, setFilterType, filterCategory, setFilterCategory, filterKeyword, setFilterKeyword, onEdit, onToggleFeatured, onDelete }) => {
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
placeholder="搜索应用..."
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="removed">已下架</option>
|
||||
</select>
|
||||
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="">全部类型</option>
|
||||
<option value="skill">Skills工具</option>
|
||||
<option value="agent">智能Agent</option>
|
||||
<option value="mcp">MCP协议</option>
|
||||
</select>
|
||||
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
{['应用', '类型', '分类', '价格', '安装', '评分', '状态', '推荐', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apps.map(app => (
|
||||
<tr key={app.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{app.icon && <img src={app.icon} alt="" style={{ width: '32px', height: '32px', borderRadius: '6px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>{app.name}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{app.publisher_name || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{TYPE_LABELS[app.type] || app.type}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{CATEGORY_LABELS[app.category] || app.category}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
|
||||
{app.price === 0 ? <span style={{ color: 'var(--color-success)' }}>免费</span> : `¥${app.price}`}
|
||||
{app.is_member_free && <span style={{ fontSize: '10px', color: 'var(--color-warning)', marginLeft: '4px' }}>会员免费</span>}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{app.install_count}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
|
||||
{app.rating_avg > 0 ? `★ ${app.rating_avg.toFixed(1)} (${app.review_count})` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: STATUS_COLORS[app.status] || 'var(--color-text-tertiary)', color: '#fff' }}>
|
||||
{STATUS_LABELS[app.status] || app.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<button
|
||||
onClick={() => onToggleFeatured(app)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: '4px', border: 'none', cursor: 'pointer', fontSize: '12px',
|
||||
background: app.featured ? 'var(--color-warning)' : 'var(--color-bg-tertiary)',
|
||||
color: app.featured ? '#fff' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{app.featured ? '已推荐' : '推荐'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button onClick={() => onEdit(app)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
|
||||
<button onClick={() => onDelete(app.id, app.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{apps.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无应用</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
|
||||
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Pending Apps Component
|
||||
// ============================================================
|
||||
const PendingApps: React.FC<{ apps: MarketAppItem[]; onApprove: (id: string) => void; onReject: (id: string) => void }> = ({ apps, onApprove, onReject }) => (
|
||||
<div>
|
||||
{apps.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
<Icon name="check" size={48} color="var(--color-success)" />
|
||||
<p style={{ marginTop: '16px', fontSize: '14px' }}>没有待审核的应用</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.id} style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
|
||||
border: '1px solid var(--color-border)', display: 'flex', gap: '16px',
|
||||
}}>
|
||||
{app.icon ? (
|
||||
<img src={app.icon} alt="" style={{ width: '56px', height: '56px', borderRadius: '10px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : (
|
||||
<div style={{ width: '56px', height: '56px', borderRadius: '10px', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="store" size={24} color="var(--color-text-tertiary)" />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>{app.name}</h4>
|
||||
<span style={{ fontSize: '11px', padding: '2px 6px', borderRadius: '4px', background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}>
|
||||
{TYPE_LABELS[app.type] || app.type}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
|
||||
by {app.publisher_name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||
{app.description || '无描述'}
|
||||
</p>
|
||||
{app.skill_content && (
|
||||
<details style={{ marginTop: '8px' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>查看 Skill 内容</summary>
|
||||
<pre style={{ marginTop: '8px', padding: '12px', background: 'var(--color-bg-tertiary)', borderRadius: '8px', fontSize: '12px', overflow: 'auto', maxHeight: '200px', color: 'var(--color-text-secondary)' }}>
|
||||
{app.skill_content.slice(0, 500)}{app.skill_content.length > 500 ? '...' : ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button onClick={() => onApprove(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-success)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Icon name="check" size={14} /> 审核通过
|
||||
</button>
|
||||
<button onClick={() => onReject(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Icon name="close" size={14} /> 拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Coupons Component
|
||||
// ============================================================
|
||||
const CouponsManagement: React.FC<{
|
||||
coupons: CouponItem[]; onAdd: () => void; onEdit: (c: CouponItem) => void; onDelete: (id: string) => void;
|
||||
}> = ({ coupons, onAdd, onEdit, onDelete }) => (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<button onClick={onAdd} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Icon name="plus" size={16} /> 创建优惠券
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
{['优惠券码', '类型', '面值', '最低消费', '有效期', '使用情况', '状态', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coupons.map(c => (
|
||||
<tr key={c.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', fontWeight: 500, fontFamily: 'monospace' }}>{c.code}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? '固定金额' : '百分比'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? `¥${c.value}` : `${c.value}%`}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>¥{c.min_amount}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>
|
||||
{c.valid_from?.slice(0, 10) || '—'} ~ {c.valid_to?.slice(0, 10) || '长期'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.used_count} / {c.total_count}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: c.status === 'active' ? 'var(--color-success)' : 'var(--color-text-tertiary)', color: '#fff' }}>
|
||||
{c.status === 'active' ? '有效' : '已停用'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button onClick={() => onEdit(c)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
|
||||
<button onClick={() => onDelete(c.id)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{coupons.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无优惠券</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Reviews List Component
|
||||
// ============================================================
|
||||
const ReviewsList: React.FC<{ reviews: ReviewItem[] }> = ({ reviews }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{reviews.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
<Icon name="star" size={48} color="var(--color-text-tertiary)" />
|
||||
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无用户评价</p>
|
||||
</div>
|
||||
) : (
|
||||
reviews.map(r => (
|
||||
<div key={r.id} style={{
|
||||
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '16px',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
{r.avatar ? (
|
||||
<img src={r.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />
|
||||
) : (
|
||||
<div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>
|
||||
{r.username?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '13px', fontWeight: 500 }}>{r.username}</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-warning)' }}>
|
||||
{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
|
||||
on {r.app_name}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', marginLeft: 'auto' }}>
|
||||
{r.created_at?.slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||
{r.content}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Edit App Modal
|
||||
// ============================================================
|
||||
const EditAppModal: React.FC<{ app: MarketAppItem; onClose: () => void; onSaved: () => void }> = ({ app, onClose, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
name: app.name,
|
||||
description: app.description || '',
|
||||
category: app.category || 'other',
|
||||
tags: app.tags || '',
|
||||
price: app.price,
|
||||
is_member_free: app.is_member_free,
|
||||
featured: app.featured,
|
||||
featured_order: app.featured_order,
|
||||
status: app.status,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminAPI.updateMarketApp(app.id, form);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
alert('保存失败');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
|
||||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '500px', maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>编辑应用</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>应用名称</label>
|
||||
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>描述</label>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)', resize: 'vertical' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>分类</label>
|
||||
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>价格 (¥)</label>
|
||||
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>标签 (逗号分隔)</label>
|
||||
<input value={form.tags} onChange={e => setForm({ ...form, tags: e.target.value })} placeholder="AI, 工具, 效率" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>状态</label>
|
||||
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="published">已发布</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="removed">已下架</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>推荐排序</label>
|
||||
<input type="number" value={form.featured_order} onChange={e => setForm({ ...form, featured_order: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.is_member_free} onChange={e => setForm({ ...form, is_member_free: e.target.checked })} />
|
||||
会员免费
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.featured} onChange={e => setForm({ ...form, featured: e.target.checked })} />
|
||||
推荐应用
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Coupon Modal
|
||||
// ============================================================
|
||||
const CouponModal: React.FC<{ coupon: CouponItem | null; onClose: () => void; onSaved: () => void }> = ({ coupon, onClose, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
code: coupon?.code || '',
|
||||
type: coupon?.type || 'fixed',
|
||||
value: coupon?.value || 10,
|
||||
min_amount: coupon?.min_amount || 0,
|
||||
valid_from: coupon?.valid_from?.slice(0, 10) || '',
|
||||
valid_to: coupon?.valid_to?.slice(0, 10) || '',
|
||||
total_count: coupon?.total_count || 100,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.code.trim()) { alert('请输入优惠券码'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (coupon) {
|
||||
await adminAPI.updateCoupon(coupon.id, form);
|
||||
} else {
|
||||
await adminAPI.addCoupon(form);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
alert('保存失败');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
|
||||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '440px', maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>{coupon ? '编辑优惠券' : '创建优惠券'}</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>优惠券码</label>
|
||||
<input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="SUMMER2026" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>类型</label>
|
||||
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
|
||||
<option value="fixed">固定金额</option>
|
||||
<option value="percent">百分比</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>面值</label>
|
||||
<input type="number" value={form.value} onChange={e => setForm({ ...form, value: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>最低消费 (¥)</label>
|
||||
<input type="number" value={form.min_amount} onChange={e => setForm({ ...form, min_amount: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>发放数量</label>
|
||||
<input type="number" value={form.total_count} onChange={e => setForm({ ...form, total_count: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>开始日期</label>
|
||||
<input type="date" value={form.valid_from} onChange={e => setForm({ ...form, valid_from: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>结束日期</label>
|
||||
<input type="date" value={form.valid_to} onChange={e => setForm({ ...form, valid_to: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Market;
|
||||
@@ -0,0 +1,405 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
|
||||
title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
|
||||
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
||||
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
|
||||
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
||||
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
|
||||
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
|
||||
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
|
||||
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
|
||||
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
|
||||
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
||||
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
|
||||
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
|
||||
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
|
||||
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
|
||||
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
|
||||
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
||||
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
|
||||
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
|
||||
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' },
|
||||
formGroup: { marginBottom: 16 },
|
||||
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
|
||||
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
|
||||
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
|
||||
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
|
||||
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
||||
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
||||
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
|
||||
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
|
||||
detailsItem: { fontSize: 13 },
|
||||
detailsLabel: { color: '#6b7280', marginBottom: 4 },
|
||||
};
|
||||
|
||||
const TRANSPORT_TYPES = [
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'stdio', label: 'STDIO' },
|
||||
{ value: 'sse', label: 'SSE' },
|
||||
];
|
||||
|
||||
const STATUS_TYPES = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'connected', label: '已连接' },
|
||||
{ value: 'disconnected', label: '未连接' },
|
||||
{ value: 'error', label: '错误' },
|
||||
];
|
||||
|
||||
interface MCPServer {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
transport_type: string;
|
||||
transport_type_label: string;
|
||||
command: string;
|
||||
args: Record<string, any>;
|
||||
env_vars: Record<string, string>;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
is_enabled: boolean;
|
||||
status: string;
|
||||
last_connected_at: string;
|
||||
tools_count: number;
|
||||
resources_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function AdminMcp() {
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
const [stats, setStats] = useState<any>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterTransport, setFilterTransport] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
|
||||
const pageSize = 20;
|
||||
|
||||
const showToast = (message: string, type: string = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminMcpStats() as any;
|
||||
if (result.code === 0) setStats(result.data);
|
||||
} catch (e) { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (search) params.search = search;
|
||||
if (filterTransport) params.transport_type = filterTransport;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
const result = await adminAPI.getAdminMcpServers(params) as any;
|
||||
if (result.code === 0) {
|
||||
setServers(result.data.items || []);
|
||||
setTotal(result.data.total || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('加载服务失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, filterTransport, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchServers();
|
||||
}, [fetchStats, fetchServers]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingServer(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (server: MCPServer) => {
|
||||
setEditingServer(server);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (server: MCPServer) => {
|
||||
if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminMcpServer(server.id);
|
||||
showToast('服务已删除');
|
||||
fetchServers();
|
||||
fetchStats();
|
||||
} catch (e) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (server: MCPServer) => {
|
||||
try {
|
||||
await adminAPI.toggleAdminMcpServer(server.id);
|
||||
showToast(server.is_enabled ? '已禁用' : '已启用');
|
||||
fetchServers();
|
||||
fetchStats();
|
||||
} catch (e) {
|
||||
showToast('切换失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (server: MCPServer) => {
|
||||
try {
|
||||
const result = await adminAPI.testAdminMcpServer(server.id) as any;
|
||||
if (result.code === 0) {
|
||||
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
|
||||
fetchServers();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('测试失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
try {
|
||||
if (editingServer) {
|
||||
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
|
||||
showToast('服务已更新');
|
||||
} else {
|
||||
await adminAPI.createAdminMcpServer(formData);
|
||||
showToast('服务已创建');
|
||||
}
|
||||
setShowModal(false);
|
||||
fetchServers();
|
||||
fetchStats();
|
||||
} catch (e: any) {
|
||||
showToast(e.message || '操作失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
|
||||
const colors: Record<string, { bg: string; color: string }> = {
|
||||
connected: { bg: '#dcfce7', color: '#166534' },
|
||||
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
|
||||
error: { bg: '#fee2e2', color: '#991b1b' },
|
||||
};
|
||||
const c = colors[status] || colors.disconnected;
|
||||
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
|
||||
};
|
||||
|
||||
const getTransportBadge = (type: string) => {
|
||||
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
|
||||
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{toast && (
|
||||
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>MCP 服务管理</h1>
|
||||
<button style={styles.addBtn} onClick={handleCreate}>+ 添加服务</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.statsRow}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>服务总数</div>
|
||||
<div style={styles.statValue}>{stats.total_servers || 0}</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>已启用</div>
|
||||
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>已连接</div>
|
||||
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>传输方式分布</div>
|
||||
<div style={{ fontSize: 13, color: '#374151' }}>
|
||||
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
|
||||
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.toolbar}>
|
||||
<input
|
||||
style={styles.searchInput}
|
||||
placeholder="搜索服务..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
|
||||
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
|
||||
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>名称</th>
|
||||
<th style={styles.th}>传输方式</th>
|
||||
<th style={styles.th}>状态</th>
|
||||
<th style={styles.th}>工具数</th>
|
||||
<th style={styles.th}>最后连接</th>
|
||||
<th style={styles.th}>启用</th>
|
||||
<th style={styles.th}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
|
||||
) : servers.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
|
||||
) : servers.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td style={styles.td}>
|
||||
<div style={{ fontWeight: 600 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
|
||||
</td>
|
||||
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
|
||||
<td style={styles.td}>{getStatusBadge(s.status)}</td>
|
||||
<td style={styles.td}>{s.tools_count || '-'}</td>
|
||||
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
|
||||
<td style={styles.td}>
|
||||
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
|
||||
{s.is_enabled ? '是' : '否'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
|
||||
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
|
||||
{s.is_enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
|
||||
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div style={styles.pagination}>
|
||||
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
|
||||
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: server?.name || '',
|
||||
description: server?.description || '',
|
||||
transport_type: server?.transport_type || 'stdio',
|
||||
command: server?.command || '',
|
||||
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
|
||||
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
|
||||
url: server?.url || '',
|
||||
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||
});
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data: any = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
transport_type: form.transport_type,
|
||||
};
|
||||
|
||||
if (form.transport_type === 'stdio') {
|
||||
data.command = form.command;
|
||||
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
|
||||
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
|
||||
} else {
|
||||
data.url = form.url;
|
||||
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
|
||||
}
|
||||
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.modalOverlay} onClick={onClose}>
|
||||
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>名称 *</label>
|
||||
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
|
||||
</div>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>描述</label>
|
||||
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
|
||||
</div>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>传输类型</label>
|
||||
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
|
||||
<option value="stdio">STDIO(命令行方式)</option>
|
||||
<option value="sse">SSE(HTTP 方式)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.transport_type === 'stdio' ? (
|
||||
<>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>命令</label>
|
||||
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
|
||||
</div>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>参数(JSON)</label>
|
||||
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
|
||||
</div>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>环境变量(JSON)</label>
|
||||
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>SSE 地址</label>
|
||||
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
|
||||
</div>
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>请求头(JSON)</label>
|
||||
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={styles.modalActions}>
|
||||
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
|
||||
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
interface McpTool {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
category: string;
|
||||
tags: string | null;
|
||||
status: string;
|
||||
publisher_name: string | null;
|
||||
price: number;
|
||||
is_member_free: boolean;
|
||||
featured: boolean;
|
||||
rating_avg: number;
|
||||
install_count: number;
|
||||
version: string;
|
||||
server_type: string;
|
||||
runtime: string | null;
|
||||
command: string | null;
|
||||
is_managed: boolean;
|
||||
security_status: string;
|
||||
security_issues: number;
|
||||
deep_link: string | null;
|
||||
package_name: string | null;
|
||||
package_registry: string | null;
|
||||
tools_count: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface McpToolDetail extends McpTool {
|
||||
app: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
status: string;
|
||||
tutorial: string | null;
|
||||
};
|
||||
args: string[] | null;
|
||||
env_vars: Record<string, string> | null;
|
||||
transport_url: string | null;
|
||||
builtin_name: string | null;
|
||||
managed_url: string | null;
|
||||
managed_port: number | null;
|
||||
package_version: string;
|
||||
package_repo_url: string | null;
|
||||
security_report: Record<string, any> | null;
|
||||
security_scanned_at: string | null;
|
||||
permissions: Record<string, any> | null;
|
||||
config_schema: Record<string, any> | null;
|
||||
install_instructions: string | null;
|
||||
tools_list: string[] | null;
|
||||
author_name: string | null;
|
||||
license: string;
|
||||
homepage_url: string | null;
|
||||
}
|
||||
|
||||
interface McpStats {
|
||||
total: number;
|
||||
published: number;
|
||||
pending_review: number;
|
||||
managed_servers: number;
|
||||
by_security_status: Record<string, number>;
|
||||
by_server_type: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
}
|
||||
|
||||
const SERVER_TYPE_LABELS: Record<string, string> = {
|
||||
stdio: 'STDIO',
|
||||
sse: 'SSE',
|
||||
streamable_http: 'HTTP',
|
||||
builtin: '内置',
|
||||
};
|
||||
|
||||
const SECURITY_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待审核', color: '#d29922' },
|
||||
scanning: { label: '扫描中', color: '#58a6ff' },
|
||||
approved: { label: '已通过', color: '#3fb950' },
|
||||
rejected: { label: '已拒绝', color: '#f85149' },
|
||||
failed: { label: '扫描失败', color: '#f85149' },
|
||||
};
|
||||
|
||||
const MCP_CATEGORY_LABELS: Record<string, string> = {
|
||||
developer: '开发工具', database: '数据库', search: '搜索检索',
|
||||
file: '文件系统', browser: '浏览器', design: '设计创意',
|
||||
data: '数据处理', communication: '通信协作', cloud: '云服务',
|
||||
productivity: '效率工具', other: '其他',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main Component
|
||||
// ============================================================
|
||||
const McpMarketTab: React.FC = () => {
|
||||
const [tools, setTools] = useState<McpTool[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<McpStats | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterServerType, setFilterServerType] = useState('');
|
||||
const [filterSecurity, setFilterSecurity] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [detailTool, setDetailTool] = useState<McpToolDetail | null>(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getMcpMarketStats();
|
||||
if (res?.code === 0) setStats(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = { page, page_size: 20 };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterServerType) params.server_type = filterServerType;
|
||||
if (filterSecurity) params.security_status = filterSecurity;
|
||||
const res = await adminAPI.getMcpTools(params);
|
||||
if (res?.code === 0) {
|
||||
setTools(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, [page, keyword, filterServerType, filterSecurity]);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
useEffect(() => { loadTools(); }, [loadTools]);
|
||||
|
||||
const handleSeed = async () => {
|
||||
if (!confirm('确认填充 Goose 内置 MCP 工具到市场?')) return;
|
||||
try {
|
||||
const res = await adminAPI.seedMcpTools();
|
||||
if (res?.code === 0) {
|
||||
alert(res.message || '填充成功');
|
||||
loadStats();
|
||||
loadTools();
|
||||
}
|
||||
} catch (e) { alert('操作失败'); }
|
||||
};
|
||||
|
||||
const handleScan = async (id: string) => {
|
||||
try {
|
||||
const res = await adminAPI.scanMcpTool(id);
|
||||
if (res?.code === 0) {
|
||||
alert(`扫描完成: ${res.data?.status} (${res.data?.issues_count} 个问题)`);
|
||||
loadTools();
|
||||
}
|
||||
} catch (e) { alert('扫描失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确认删除 MCP 工具「${name}」?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteMcpTool(id);
|
||||
loadTools();
|
||||
loadStats();
|
||||
} catch (e) { alert('删除失败'); }
|
||||
};
|
||||
|
||||
const handleViewDetail = async (id: string) => {
|
||||
try {
|
||||
const res = await adminAPI.getMcpToolDetail(id);
|
||||
if (res?.code === 0) setDetailTool(res.data);
|
||||
} catch (e) { alert('加载详情失败'); }
|
||||
};
|
||||
|
||||
const copyDeepLink = (link: string) => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
alert('Deep Link 已复制到剪贴板!');
|
||||
}).catch(() => {
|
||||
prompt('请手动复制:', link);
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '20px' }}>
|
||||
<StatCard label="MCP 工具总数" value={stats.total} sub={`${stats.published} 已发布`} color="var(--color-primary, #58a6ff)" />
|
||||
<StatCard label="待审核" value={stats.pending_review} sub="安全审核中" color="#d29922" />
|
||||
<StatCard label="托管服务器" value={stats.managed_servers} sub="平台托管" color="#3fb950" />
|
||||
<StatCard label="已通过审核" value={stats.by_security_status?.approved || 0} sub="可发布" color="#bc8cff" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 MCP 工具..."
|
||||
value={keyword}
|
||||
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||
style={{
|
||||
flex: 1, minWidth: 200, padding: '8px 12px',
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 8, color: 'var(--color-text-primary, #e6edf3)', fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={filterServerType}
|
||||
onChange={(e) => { setFilterServerType(e.target.value); setPage(1); }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterSecurity}
|
||||
onChange={(e) => { setFilterSecurity(e.target.value); setPage(1); }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(SECURITY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={handleSeed} style={btnSecondaryStyle}>填充内置工具</button>
|
||||
<button onClick={() => setShowCreate(true)} style={btnPrimaryStyle}>+ 创建 MCP 工具</button>
|
||||
</div>
|
||||
|
||||
{/* Tools List */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>加载中...</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
|
||||
暂无 MCP 工具。点击"填充内置工具"快速导入 Goose 内置扩展。
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{tools.map(tool => (
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
onView={() => handleViewDetail(tool.id)}
|
||||
onScan={() => handleScan(tool.id)}
|
||||
onDelete={() => handleDelete(tool.id, tool.name)}
|
||||
onCopyLink={() => tool.deep_link && copyDeepLink(tool.deep_link)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={pageBtnStyle}>上一页</button>
|
||||
<span style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={pageBtnStyle}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<CreateMcpToolModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSaved={() => { setShowCreate(false); loadTools(); loadStats(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{detailTool && (
|
||||
<McpToolDetailModal
|
||||
tool={detailTool}
|
||||
onClose={() => setDetailTool(null)}
|
||||
onCopyLink={() => detailTool.deep_link && copyDeepLink(detailTool.deep_link)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Sub Components
|
||||
// ============================================================
|
||||
|
||||
const StatCard: React.FC<{ label: string; value: number; sub: string; color: string }> = ({ label, value, sub, color }) => (
|
||||
<div style={{
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
}}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>{label}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 4 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ToolCard: React.FC<{
|
||||
tool: McpTool;
|
||||
onView: () => void;
|
||||
onScan: () => void;
|
||||
onDelete: () => void;
|
||||
onCopyLink: () => void;
|
||||
}> = ({ tool, onView, onScan, onDelete, onCopyLink }) => {
|
||||
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
|
||||
const stLabel = SERVER_TYPE_LABELS[tool.server_type] || tool.server_type;
|
||||
const catLabel = MCP_CATEGORY_LABELS[tool.category] || tool.category;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
borderRadius: 12, padding: 16,
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 16,
|
||||
}}>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 10, flexShrink: 0,
|
||||
background: tool.server_type === 'builtin' ? 'linear-gradient(135deg, #58a6ff, #bc8cff)' : 'var(--color-bg-tertiary, #21262d)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: '#fff',
|
||||
}}>
|
||||
{tool.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text-primary, #e6edf3)' }}>{tool.name}</span>
|
||||
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
|
||||
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{stLabel}</span>
|
||||
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>托管</span>}
|
||||
{tool.tools_count > 0 && <span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} tools</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 6 }}>
|
||||
{tool.description || '暂无描述'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', display: 'flex', gap: 12 }}>
|
||||
<span>{catLabel}</span>
|
||||
{tool.package_name && <span>{tool.package_name}</span>}
|
||||
{tool.publisher_name && <span>by {tool.publisher_name}</span>}
|
||||
{tool.security_issues > 0 && <span style={{ color: '#f85149' }}>{tool.security_issues} 个安全问题</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button onClick={onView} style={btnSmallStyle}>详情</button>
|
||||
{tool.deep_link && <button onClick={onCopyLink} style={btnSmallStyle}>Deep Link</button>}
|
||||
<button onClick={onScan} style={btnSmallStyle}>扫描</button>
|
||||
<button onClick={onDelete} style={{ ...btnSmallStyle, color: '#f85149' }}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateMcpToolModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
name: '', description: '', category: 'developer',
|
||||
server_type: 'stdio', runtime: 'node', command: '', args: '',
|
||||
package_name: '', package_registry: 'npm', package_repo_url: '',
|
||||
transport_url: '', builtin_name: '',
|
||||
author_name: '', license: 'MIT', tools_count: 0,
|
||||
install_instructions: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) { alert('请输入工具名称'); return; }
|
||||
if (form.server_type === 'stdio' && !form.command) { alert('STDIO 类型需要启动命令'); return; }
|
||||
if (form.server_type === 'builtin' && !form.builtin_name) { alert('内置类型需要扩展名称'); return; }
|
||||
if ((form.server_type === 'sse' || form.server_type === 'streamable_http') && !form.transport_url) { alert('SSE/HTTP 类型需要传输 URL'); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, any> = { ...form };
|
||||
if (form.args) {
|
||||
try { payload.args = JSON.parse(form.args); } catch { payload.args = form.args.split(' '); }
|
||||
} else {
|
||||
delete payload.args;
|
||||
}
|
||||
if (!form.tools_count) delete payload.tools_count;
|
||||
if (!form.transport_url) delete payload.transport_url;
|
||||
if (!form.builtin_name) delete payload.builtin_name;
|
||||
|
||||
const res = await adminAPI.createMcpTool(payload);
|
||||
if (res?.code === 0) {
|
||||
alert(`创建成功!安全状态: ${res.data?.security_status || 'pending'}`);
|
||||
onSaved();
|
||||
} else {
|
||||
alert(res?.message || '创建失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert('创建失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={modalOverlayStyle}>
|
||||
<div style={modalContentStyle}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20, color: 'var(--color-text-primary)' }}>创建 MCP 工具</h2>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
|
||||
<FormField label="工具名称 *">
|
||||
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如: Filesystem MCP" />
|
||||
</FormField>
|
||||
<FormField label="描述">
|
||||
<textarea style={{ ...inputStyle, minHeight: 60 }} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</FormField>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<FormField label="分类">
|
||||
<select style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{Object.entries(MCP_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="服务器类型">
|
||||
<select style={inputStyle} value={form.server_type} onChange={(e) => setForm({ ...form, server_type: e.target.value })}>
|
||||
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{form.server_type === 'stdio' && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<FormField label="运行时">
|
||||
<select style={inputStyle} value={form.runtime} onChange={(e) => setForm({ ...form, runtime: e.target.value })}>
|
||||
<option value="node">Node.js</option><option value="python">Python</option>
|
||||
<option value="go">Go</option><option value="rust">Rust</option>
|
||||
<option value="binary">二进制</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="启动命令 *">
|
||||
<input style={inputStyle} value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="如: npx" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="参数 (JSON 数组或空格分隔)">
|
||||
<input style={inputStyle} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} placeholder='如: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]' />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{form.server_type === 'builtin' && (
|
||||
<FormField label="内置扩展名 *">
|
||||
<input style={inputStyle} value={form.builtin_name} onChange={(e) => setForm({ ...form, builtin_name: e.target.value })} placeholder="如: developer" />
|
||||
</FormField>
|
||||
)}
|
||||
{(form.server_type === 'sse' || form.server_type === 'streamable_http') && (
|
||||
<FormField label="传输 URL *">
|
||||
<input style={inputStyle} value={form.transport_url} onChange={(e) => setForm({ ...form, transport_url: e.target.value })} placeholder="https://..." />
|
||||
</FormField>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<FormField label="包名">
|
||||
<input style={inputStyle} value={form.package_name} onChange={(e) => setForm({ ...form, package_name: e.target.value })} placeholder="@modelcontextprotocol/server-filesystem" />
|
||||
</FormField>
|
||||
<FormField label="包源">
|
||||
<select style={inputStyle} value={form.package_registry} onChange={(e) => setForm({ ...form, package_registry: e.target.value })}>
|
||||
<option value="npm">npm</option><option value="pypi">pypi</option>
|
||||
<option value="github">GitHub</option><option value="custom">自定义</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="仓库 URL">
|
||||
<input style={inputStyle} value={form.package_repo_url} onChange={(e) => setForm({ ...form, package_repo_url: e.target.value })} placeholder="https://github.com/..." />
|
||||
</FormField>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<FormField label="作者">
|
||||
<input style={inputStyle} value={form.author_name} onChange={(e) => setForm({ ...form, author_name: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="许可证">
|
||||
<input style={inputStyle} value={form.license} onChange={(e) => setForm({ ...form, license: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="工具数量">
|
||||
<input type="number" style={inputStyle} value={form.tools_count} onChange={(e) => setForm({ ...form, tools_count: parseInt(e.target.value) || 0 })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button onClick={onClose} style={btnSecondaryStyle}>取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving} style={btnPrimaryStyle}>{saving ? '创建中...' : '创建'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const McpToolDetailModal: React.FC<{ tool: McpToolDetail; onClose: () => void; onCopyLink: () => void }> = ({ tool, onClose, onCopyLink }) => {
|
||||
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
|
||||
|
||||
return (
|
||||
<div style={modalOverlayStyle}>
|
||||
<div style={{ ...modalContentStyle, maxWidth: 700 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text-primary)' }}>{tool.app.name}</h2>
|
||||
<button onClick={onClose} style={{ ...btnSmallStyle, fontSize: 18 }}>x</button>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
|
||||
{/* Badges */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
|
||||
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{SERVER_TYPE_LABELS[tool.server_type] || tool.server_type}</span>
|
||||
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>平台托管</span>}
|
||||
<span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} 个工具</span>
|
||||
<span style={{ ...badgeStyle, background: '#bc8cff22', color: '#bc8cff' }}>{tool.license}</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{tool.app.description && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>描述</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{tool.app.description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deep Link */}
|
||||
{tool.deep_link && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>Goose Deep Link</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input readOnly value={tool.deep_link} style={{ ...inputStyle, flex: 1, fontFamily: 'monospace', fontSize: 12 }} />
|
||||
<button onClick={onCopyLink} style={btnPrimaryStyle}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
{tool.command && <DetailItem label="启动命令" value={tool.command} />}
|
||||
{tool.runtime && <DetailItem label="运行时" value={tool.runtime} />}
|
||||
{tool.transport_url && <DetailItem label="传输 URL" value={tool.transport_url} />}
|
||||
{tool.builtin_name && <DetailItem label="内置扩展" value={tool.builtin_name} />}
|
||||
{tool.package_name && <DetailItem label="包名" value={tool.package_name} />}
|
||||
<DetailItem label="版本" value={tool.package_version} />
|
||||
{tool.author_name && <DetailItem label="作者" value={tool.author_name} />}
|
||||
{tool.managed_url && <DetailItem label="托管 URL" value={tool.managed_url} />}
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
{tool.args && tool.args.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>启动参数</div>
|
||||
<code style={{ ...codeBlockStyle, display: 'block' }}>{JSON.stringify(tool.args, null, 2)}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools List */}
|
||||
{tool.tools_list && tool.tools_list.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>暴露的工具 ({tool.tools_list.length})</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{tool.tools_list.map((t, i) => (
|
||||
<span key={i} style={{ ...badgeStyle, background: '#30363d', color: '#8b949e', fontFamily: 'monospace' }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{tool.permissions && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>权限声明</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{Object.entries(tool.permissions).map(([k, v]) => (
|
||||
<span key={k} style={{ ...badgeStyle, background: v ? '#f8514922' : '#30363d', color: v ? '#f85149' : '#8b949e' }}>
|
||||
{k}: {v ? '是' : '否'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Report */}
|
||||
{tool.security_report && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>安全扫描报告</div>
|
||||
<div style={{ ...codeBlockStyle, whiteSpace: 'pre-wrap' }}>{JSON.stringify(tool.security_report, null, 2)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Instructions */}
|
||||
{tool.install_instructions && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>安装说明</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap' }}>{tool.install_instructions}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button onClick={onClose} style={btnSecondaryStyle}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Helper Components & Styles
|
||||
// ============================================================
|
||||
|
||||
const FormField: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetailItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Style constants
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-text-primary, #e6edf3)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-bg-tertiary, #21262d)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-text-primary, #e6edf3)',
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const btnPrimaryStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-primary, #58a6ff)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnSecondaryStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-bg-tertiary, #21262d)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const btnSmallStyle: React.CSSProperties = {
|
||||
padding: '4px 10px',
|
||||
background: 'var(--color-bg-tertiary, #21262d)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const pageBtnStyle: React.CSSProperties = {
|
||||
padding: '6px 14px',
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const badgeStyle: React.CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: 'inline-block',
|
||||
};
|
||||
|
||||
const modalOverlayStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalContentStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary, #161b22)',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: '90%',
|
||||
maxWidth: 600,
|
||||
maxHeight: '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '1px solid var(--color-border, #30363d)',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
marginBottom: 4,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const codeBlockStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-tertiary, #21262d)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'monospace',
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
export default McpMarketTab;
|
||||
@@ -0,0 +1,680 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const levelColors: Record<number, { bg: string; accent: string; label: string }> = {
|
||||
0: { bg: 'var(--color-bg-tertiary)', accent: 'var(--color-text-secondary)', label: '免费版' },
|
||||
1: { bg: 'rgba(99,102,241,0.1)', accent: '#818cf8', label: '基础版' },
|
||||
2: { bg: 'rgba(34,197,94,0.1)', accent: '#22c55e', label: '专业版' },
|
||||
3: { bg: 'rgba(245,158,11,0.1)', accent: '#f59e0b', label: '企业版' },
|
||||
};
|
||||
|
||||
const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, border: 'none',
|
||||
background: checked ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background 0.2s', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: checked ? 22 : 3,
|
||||
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
|
||||
interface Plan {
|
||||
id: string; name: string; level: number; duration_type: string; price: number;
|
||||
daily_free_tokens: number; features: string[]; free_app_ids: string[]; free_model_ids: string[];
|
||||
is_active: boolean;
|
||||
max_agents: number; max_workflows: number; max_knowledge_bases: number; max_storage_mb: number;
|
||||
api_calls_per_day: number; api_calls_per_min: number;
|
||||
can_publish_apps: boolean; can_create_api_keys: boolean; can_use_workflow: boolean;
|
||||
can_use_mcp: boolean; can_use_code_exec: boolean; can_use_privacy_mode: boolean; can_use_dag: boolean;
|
||||
allowed_model_tiers: string[]; permissions: Record<string, any>;
|
||||
}
|
||||
|
||||
interface UserMembership {
|
||||
user_id: string; username: string; email: string;
|
||||
membership_plan_id: string; membership_plan_name: string; membership_level: number;
|
||||
membership_expire_at: string; auto_renew: boolean; member_status: string;
|
||||
}
|
||||
|
||||
interface GlobalConfig {
|
||||
id: string; key: string; value: any; type: string; description: string;
|
||||
}
|
||||
|
||||
const Membership: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'plans' | 'benefits' | 'users' | 'configs' | 'stats'>('plans');
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [assignUserId, setAssignUserId] = useState('');
|
||||
const [assignPlanId, setAssignPlanId] = useState('');
|
||||
const [assignDays, setAssignDays] = useState(30);
|
||||
|
||||
// User memberships
|
||||
const [userMemberships, setUserMemberships] = useState<UserMembership[]>([]);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
|
||||
// Configs
|
||||
const [configs, setConfigs] = useState<GlobalConfig[]>([]);
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getPlans();
|
||||
setPlans(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
|
||||
} catch (e) { console.error('fetchPlans:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchUserMemberships = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getUserMemberships(userPage);
|
||||
const d = res.data || res;
|
||||
setUserMemberships(d?.items || []);
|
||||
setUserTotal(d?.total || 0);
|
||||
} catch (e) { console.error('fetchUserMemberships:', e); }
|
||||
}, [userPage]);
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getMembershipConfigs();
|
||||
setConfigs(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
|
||||
} catch (e) { console.error('fetchConfigs:', e); }
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getMembershipStats();
|
||||
setStats(res.data || res);
|
||||
} catch (e) { console.error('fetchStats:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [fetchPlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'users') fetchUserMemberships();
|
||||
if (activeTab === 'configs') fetchConfigs();
|
||||
if (activeTab === 'stats') fetchStats();
|
||||
}, [activeTab, fetchUserMemberships, fetchConfigs, fetchStats]);
|
||||
|
||||
// ===================== Plan Edit Form =====================
|
||||
const emptyForm: Plan = {
|
||||
id: '', name: '', level: 0, duration_type: 'month', price: 0, daily_free_tokens: 0,
|
||||
features: [], free_app_ids: [], free_model_ids: [], is_active: true,
|
||||
max_agents: 5, max_workflows: 3, max_knowledge_bases: 2, max_storage_mb: 1024,
|
||||
api_calls_per_day: 100, api_calls_per_min: 10,
|
||||
can_publish_apps: false, can_create_api_keys: false, can_use_workflow: false,
|
||||
can_use_mcp: false, can_use_code_exec: false, can_use_privacy_mode: false, can_use_dag: false,
|
||||
allowed_model_tiers: ['free'], permissions: {},
|
||||
};
|
||||
const [form, setForm] = useState<Plan>(emptyForm);
|
||||
const [newFeature, setNewFeature] = useState('');
|
||||
|
||||
const handleEdit = (plan: Plan) => {
|
||||
setEditingPlan(plan);
|
||||
setForm({ ...plan });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingPlan(null);
|
||||
setForm({ ...emptyForm });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
Object.keys(form).forEach(k => {
|
||||
if (k === 'id' || k === 'created_at' || k === 'updated_at') return;
|
||||
const val = (form as any)[k];
|
||||
if (val === null || val === undefined) return;
|
||||
if (typeof val === 'boolean') params.append(k, String(val));
|
||||
else if (Array.isArray(val)) params.append(k, JSON.stringify(val));
|
||||
else if (typeof val === 'object') params.append(k, JSON.stringify(val));
|
||||
else params.append(k, String(val));
|
||||
});
|
||||
if (editingPlan) {
|
||||
await adminAPI.updatePlan(editingPlan.id, `?${params.toString()}`);
|
||||
} else {
|
||||
await adminAPI.addPlan(`?${params.toString()}`);
|
||||
}
|
||||
setShowModal(false);
|
||||
fetchPlans();
|
||||
} catch (e) { console.error('handleSave:', e); alert('保存失败: ' + (e as any)?.message); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此会员计划?')) return;
|
||||
try {
|
||||
await adminAPI.deletePlan(id);
|
||||
fetchPlans();
|
||||
} catch (e) { alert('删除失败: ' + (e as any)?.message); }
|
||||
};
|
||||
|
||||
const addFeature = () => {
|
||||
if (newFeature.trim()) {
|
||||
setForm({ ...form, features: [...form.features, newFeature.trim()] });
|
||||
setNewFeature('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModelTier = (tier: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
allowed_model_tiers: prev.allowed_model_tiers.includes(tier)
|
||||
? prev.allowed_model_tiers.filter(t => t !== tier)
|
||||
: [...prev.allowed_model_tiers, tier],
|
||||
}));
|
||||
};
|
||||
|
||||
// ===================== Assign Plan =====================
|
||||
const handleAssignPlan = async () => {
|
||||
if (!assignUserId || !assignPlanId) { alert('请填写用户ID和选择计划'); return; }
|
||||
try {
|
||||
await adminAPI.assignPlan(assignUserId, assignPlanId, assignDays);
|
||||
setShowAssignModal(false);
|
||||
setAssignUserId(''); setAssignPlanId(''); setAssignDays(30);
|
||||
fetchUserMemberships();
|
||||
} catch (e) { alert('分配失败: ' + (e as any)?.message); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (userId: string, username: string) => {
|
||||
if (!confirm(`确定撤销 ${username} 的会员?`)) return;
|
||||
try {
|
||||
await adminAPI.revokeMembership(userId);
|
||||
fetchUserMemberships();
|
||||
} catch (e) { alert('撤销失败: ' + (e as any)?.message); }
|
||||
};
|
||||
|
||||
const handleUpdateConfig = async (key: string, value: any) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('value', String(value));
|
||||
await adminAPI.updateMembershipConfig(key, `?${params.toString()}`);
|
||||
fetchConfigs();
|
||||
} catch (e) { alert('更新失败: ' + (e as any)?.message); }
|
||||
};
|
||||
|
||||
// ===================== Render =====================
|
||||
const tabs = [
|
||||
{ key: 'plans', label: '会员计划', icon: <><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/></> },
|
||||
{ key: 'benefits', label: '权益对比', icon: <><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="3" y1="9" x2="21" y2="9"/></> },
|
||||
{ key: 'users', label: '用户会员', icon: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></> },
|
||||
{ key: 'configs', label: '全局配置', icon: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></> },
|
||||
{ key: 'stats', label: '统计概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
|
||||
];
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
|
||||
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
|
||||
};
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
padding: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||
<h1 className="admin-page-title">会员订阅体系管理</h1>
|
||||
{activeTab === 'plans' && (
|
||||
<button className="admin-btn admin-btn-primary" onClick={handleCreate}>
|
||||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
新建计划
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
style={{
|
||||
padding: '10px 20px', border: 'none', cursor: 'pointer',
|
||||
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
|
||||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<SVGIcon size={14}>{tab.icon}</SVGIcon>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ===================== Plans Tab ===================== */}
|
||||
{activeTab === 'plans' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: 'var(--space-xl)' }}>
|
||||
{plans.map((plan) => {
|
||||
const colors = levelColors[plan.level] || levelColors[0];
|
||||
return (
|
||||
<div key={plan.id} className="admin-card" style={{ marginBottom: 0, borderTop: `3px solid ${colors.accent}` }}>
|
||||
<div className="admin-card-header">
|
||||
<div>
|
||||
<span className="admin-badge" style={{ background: colors.bg, color: colors.accent, marginBottom: 4 }}>
|
||||
{colors.label} · Level {plan.level}
|
||||
</span>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginTop: 4 }}>{plan.name}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(plan)}>
|
||||
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDelete(plan.id)}>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-lg)' }}>
|
||||
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>价格</div>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>¥{plan.price}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>每日Token</div>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{plan.daily_free_tokens?.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quotas */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6, marginBottom: 'var(--space-md)' }}>
|
||||
{[
|
||||
{ label: 'Agent', value: plan.max_agents },
|
||||
{ label: '工作流', value: plan.max_workflows },
|
||||
{ label: '知识库', value: plan.max_knowledge_bases },
|
||||
{ label: '存储(MB)', value: plan.max_storage_mb },
|
||||
{ label: 'API/日', value: plan.api_calls_per_day },
|
||||
{ label: 'API/分', value: plan.api_calls_per_min },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ padding: '6px 8px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 'var(--text-2xs)', color: 'var(--color-text-tertiary)' }}>{item.label}</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{item.value >= 999 ? '∞' : item.value?.toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-md)' }}>
|
||||
{[
|
||||
{ key: 'can_publish_apps', label: '发布应用' },
|
||||
{ key: 'can_create_api_keys', label: 'API密钥' },
|
||||
{ key: 'can_use_workflow', label: '工作流' },
|
||||
{ key: 'can_use_mcp', label: 'MCP' },
|
||||
{ key: 'can_use_code_exec', label: '代码执行' },
|
||||
{ key: 'can_use_privacy_mode', label: '隐私模式' },
|
||||
{ key: 'can_use_dag', label: 'DAG' },
|
||||
].map(perm => {
|
||||
const enabled = (plan as any)[perm.key];
|
||||
return enabled ? (
|
||||
<span key={perm.key} className="admin-badge" style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e', fontSize: 'var(--text-2xs)' }}>
|
||||
✓ {perm.label}
|
||||
</span>
|
||||
) : (
|
||||
<span key={perm.key} className="admin-badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-2xs)' }}>
|
||||
✗ {perm.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
{plan.features && plan.features.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', fontWeight: 600, marginBottom: 4, color: 'var(--color-text-secondary)' }}>会员权益</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{plan.features.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
|
||||
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Benefits Comparison Tab ===================== */}
|
||||
{activeTab === 'benefits' && (
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ minWidth: 160 }}>权益项</th>
|
||||
{plans.map(p => {
|
||||
const c = levelColors[p.level] || levelColors[0];
|
||||
return <th key={p.id} style={{ textAlign: 'center', minWidth: 120 }}>
|
||||
<span className="admin-badge" style={{ background: c.bg, color: c.accent }}>{p.name}</span>
|
||||
</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ key: 'price', label: '月价格(元)', type: 'number' },
|
||||
{ key: 'daily_free_tokens', label: '每日免费Token', type: 'number' },
|
||||
{ key: 'max_agents', label: '最大Agent数', type: 'number' },
|
||||
{ key: 'max_workflows', label: '最大工作流数', type: 'number' },
|
||||
{ key: 'max_knowledge_bases', label: '最大知识库数', type: 'number' },
|
||||
{ key: 'max_storage_mb', label: '存储配额(MB)', type: 'number' },
|
||||
{ key: 'api_calls_per_day', label: '每日API限额', type: 'number' },
|
||||
{ key: 'api_calls_per_min', label: '每分钟API限额', type: 'number' },
|
||||
{ key: 'can_publish_apps', label: '发布应用到市场', type: 'boolean' },
|
||||
{ key: 'can_create_api_keys', label: '创建开发者API密钥', type: 'boolean' },
|
||||
{ key: 'can_use_workflow', label: '工作流编排', type: 'boolean' },
|
||||
{ key: 'can_use_mcp', label: 'MCP工具', type: 'boolean' },
|
||||
{ key: 'can_use_code_exec', label: '代码执行', type: 'boolean' },
|
||||
{ key: 'can_use_privacy_mode', label: '隐私模式', type: 'boolean' },
|
||||
{ key: 'can_use_dag', label: 'DAG多Agent', type: 'boolean' },
|
||||
{ key: 'allowed_model_tiers', label: '可用模型层级', type: 'list' },
|
||||
].map(row => (
|
||||
<tr key={row.key}>
|
||||
<td style={{ fontWeight: 600 }}>{row.label}</td>
|
||||
{plans.map(p => {
|
||||
const val = (p as any)[row.key];
|
||||
let display;
|
||||
if (row.type === 'boolean') {
|
||||
display = val ? <span style={{ color: '#22c55e' }}>✓</span> : <span style={{ color: 'var(--color-text-tertiary)' }}>✗</span>;
|
||||
} else if (row.type === 'number') {
|
||||
display = val >= 999 ? '不限' : (val?.toLocaleString() || '0');
|
||||
} else if (row.type === 'list') {
|
||||
display = Array.isArray(val) ? val.join(', ') : '-';
|
||||
} else {
|
||||
display = String(val ?? '-');
|
||||
}
|
||||
return <td key={p.id} style={{ textAlign: 'center', fontFamily: row.type === 'number' ? 'var(--font-mono)' : 'inherit' }}>{display}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Users Tab ===================== */}
|
||||
{activeTab === 'users' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 'var(--space-lg)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="admin-btn admin-btn-primary" onClick={() => setShowAssignModal(true)}>
|
||||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
分配会员
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th><th>邮箱</th><th>会员计划</th><th>等级</th>
|
||||
<th>到期时间</th><th>自动续费</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userMemberships.length === 0 ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||||
) : userMemberships.map(u => (
|
||||
<tr key={u.user_id}>
|
||||
<td style={{ fontWeight: 600 }}>{u.username}</td>
|
||||
<td>{u.email || '-'}</td>
|
||||
<td>{u.membership_plan_name}</td>
|
||||
<td>Level {u.membership_level}</td>
|
||||
<td>{u.membership_expire_at ? new Date(u.membership_expire_at).toLocaleString('zh-CN') : '-'}</td>
|
||||
<td>{u.auto_renew ? '✓' : '✗'}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${u.member_status === 'active' ? 'badge-success' : u.member_status === 'expired' ? 'badge-danger' : 'badge-neutral'}`}>
|
||||
{u.member_status === 'active' ? '活跃' : u.member_status === 'expired' ? '已过期' : '免费'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{u.membership_level > 0 && (
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleRevoke(u.user_id, u.username)}>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{userTotal > 20 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 'var(--space-md)', marginTop: 'var(--space-lg)' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage <= 1} onClick={() => setUserPage(p => p - 1)}>上一页</button>
|
||||
<span style={{ lineHeight: '32px', fontSize: 'var(--text-sm)' }}>第 {userPage} 页</span>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage * 20 >= userTotal} onClick={() => setUserPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Configs Tab ===================== */}
|
||||
{activeTab === 'configs' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header"><h3 className="admin-card-title">全局会员配置</h3></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||||
{configs.map(cfg => (
|
||||
<div key={cfg.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{cfg.description}</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{cfg.key}</div>
|
||||
</div>
|
||||
{cfg.type === 'boolean' ? (
|
||||
<Toggle checked={!!cfg.value} onChange={(v) => handleUpdateConfig(cfg.key, v)} />
|
||||
) : cfg.type === 'number' ? (
|
||||
<input type="number" value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 80 }} />
|
||||
) : (
|
||||
<input value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 200 }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Stats Tab ===================== */}
|
||||
{activeTab === 'stats' && stats && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-xl)' }}>
|
||||
{[
|
||||
{ label: '总用户数', value: stats.total_users, color: '#818cf8' },
|
||||
{ label: '活跃会员', value: stats.active_members, color: '#22c55e' },
|
||||
{ label: '已过期', value: stats.expired_members, color: '#ef4444' },
|
||||
{ label: '免费用户', value: stats.free_users, color: 'var(--color-text-secondary)' },
|
||||
{ label: '自动续费', value: stats.auto_renew_count, color: '#f59e0b' },
|
||||
{ label: '今日新增', value: stats.today_new_subs, color: '#06b6d4' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value?.toLocaleString()}</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header"><h3 className="admin-card-title">各计划订阅统计</h3></div>
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>计划名称</th><th>等级</th><th>用户数</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.plan_stats?.map((ps: any) => (
|
||||
<tr key={ps.plan_id}>
|
||||
<td style={{ fontWeight: 600 }}>{ps.plan_name}</td>
|
||||
<td>Level {ps.level}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{ps.user_count}</td>
|
||||
<td>{ps.is_active ? <span className="admin-badge badge-success">启用</span> : <span className="admin-badge badge-neutral">停用</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Edit/Create Plan Modal ===================== */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', zIndex: 999,
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: '4vh', animation: 'fadeIn 0.2s',
|
||||
}} onClick={() => setShowModal(false)}>
|
||||
<div style={{
|
||||
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)',
|
||||
width: 720, maxHeight: '90vh', overflow: 'auto',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>
|
||||
{editingPlan ? `编辑 ${editingPlan.name}` : '新建会员计划'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>基础信息</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>计划名称</label><input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><label style={labelStyle}>等级 (0-3)</label><input type="number" style={inputStyle} value={form.level} onChange={e => setForm({ ...form, level: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>周期</label><select style={inputStyle} value={form.duration_type} onChange={e => setForm({ ...form, duration_type: e.target.value })}><option value="month">月</option><option value="quarter">季</option><option value="year">年</option></select></div>
|
||||
<div><label style={labelStyle}>价格(元)</label><input type="number" style={inputStyle} value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>每日免费Token</label><input type="number" style={inputStyle} value={form.daily_free_tokens} onChange={e => setForm({ ...form, daily_free_tokens: Number(e.target.value) })} /></div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<Toggle checked={form.is_active} onChange={v => setForm({ ...form, is_active: v })} />
|
||||
<span style={{ fontSize: 'var(--text-sm)' }}>启用</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quotas */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>配额限制</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>最大Agent数</label><input type="number" style={inputStyle} value={form.max_agents} onChange={e => setForm({ ...form, max_agents: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>最大工作流数</label><input type="number" style={inputStyle} value={form.max_workflows} onChange={e => setForm({ ...form, max_workflows: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>最大知识库数</label><input type="number" style={inputStyle} value={form.max_knowledge_bases} onChange={e => setForm({ ...form, max_knowledge_bases: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>存储配额(MB)</label><input type="number" style={inputStyle} value={form.max_storage_mb} onChange={e => setForm({ ...form, max_storage_mb: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>每日API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_day} onChange={e => setForm({ ...form, api_calls_per_day: Number(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>每分钟API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_min} onChange={e => setForm({ ...form, api_calls_per_min: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>功能权限</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
{[
|
||||
{ key: 'can_publish_apps', label: '发布应用到市场' },
|
||||
{ key: 'can_create_api_keys', label: '创建开发者API密钥' },
|
||||
{ key: 'can_use_workflow', label: '使用工作流编排' },
|
||||
{ key: 'can_use_mcp', label: '使用MCP工具' },
|
||||
{ key: 'can_use_code_exec', label: '使用代码执行' },
|
||||
{ key: 'can_use_privacy_mode', label: '使用隐私模式' },
|
||||
{ key: 'can_use_dag', label: '使用DAG多Agent' },
|
||||
].map(perm => (
|
||||
<label key={perm.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', cursor: 'pointer' }}>
|
||||
<span style={{ fontSize: 'var(--text-sm)' }}>{perm.label}</span>
|
||||
<Toggle checked={(form as any)[perm.key]} onChange={v => setForm({ ...form, [perm.key]: v })} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 'var(--space-md)' }}>
|
||||
<label style={labelStyle}>可用模型层级</label>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
{['free', 'standard', 'premium'].map(tier => (
|
||||
<label key={tier} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', padding: '4px 12px', background: form.allowed_model_tiers.includes(tier) ? 'var(--color-accent-subtle)' : 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-full)', fontSize: 'var(--text-sm)' }}>
|
||||
<input type="checkbox" checked={form.allowed_model_tiers.includes(tier)} onChange={() => toggleModelTier(tier)} style={{ accentColor: 'var(--color-accent)' }} />
|
||||
{tier}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>会员权益标签</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-sm)' }}>
|
||||
{form.features.map((f, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 10px', borderRadius: 'var(--radius-full)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', fontSize: 'var(--text-xs)' }}>
|
||||
{f}
|
||||
<button onClick={() => setForm({ ...form, features: form.features.filter((_, j) => j !== i) })} style={{ cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, border: 'none', background: 'none' }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
<input style={{ ...inputStyle, flex: 1 }} value={newFeature} onChange={e => setNewFeature(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFeature(); } }} placeholder="输入权益名称,回车添加" />
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={addFeature}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)' }}>
|
||||
<button className="admin-btn admin-btn-outline" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Assign Plan Modal ===================== */}
|
||||
{showAssignModal && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', zIndex: 999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}} onClick={() => setShowAssignModal(false)}>
|
||||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>分配会员计划</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>用户ID</label><input style={inputStyle} value={assignUserId} onChange={e => setAssignUserId(e.target.value)} placeholder="输入用户ID" /></div>
|
||||
<div><label style={labelStyle}>选择计划</label>
|
||||
<select style={inputStyle} value={assignPlanId} onChange={e => setAssignPlanId(e.target.value)}>
|
||||
<option value="">请选择...</option>
|
||||
{plans.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name} (Level {p.level})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label style={labelStyle}>订阅时长(天)</label><input type="number" style={inputStyle} value={assignDays} onChange={e => setAssignDays(Number(e.target.value))} /></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
|
||||
<button className="admin-btn admin-btn-outline" onClick={() => setShowAssignModal(false)}>取消</button>
|
||||
<button className="admin-btn admin-btn-primary" onClick={handleAssignPlan}>确认分配</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Membership;
|
||||
@@ -0,0 +1,766 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
|
||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||
return n.toLocaleString();
|
||||
};
|
||||
|
||||
const capabilityColors: Record<string, string> = {
|
||||
text: '#5B5FED', vision: '#219B5E', audio: '#E8922C',
|
||||
video: '#D9383A', image: '#D9383A', thinking: '#8B5CF6', tool: '#3B82F6',
|
||||
};
|
||||
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
text: '文本', vision: '视觉', audio: '音频',
|
||||
video: '视频', image: '图像', thinking: '思考', tool: '工具',
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
interface EditingModel {
|
||||
id?: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
provider_id: string;
|
||||
api_key: string;
|
||||
endpoint: string;
|
||||
capabilities: string[];
|
||||
timeout: number;
|
||||
status: string;
|
||||
price_per_million_tokens: number;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
// Stats panel
|
||||
const ModelStatsPanel: React.FC<{ models: AIModel[]; providers: ModelProvider[] }> = ({ models, providers }) => {
|
||||
const enabled = models.filter(m => m.isEnabled).length;
|
||||
const defaults = models.filter(m => m.isDefault).length;
|
||||
const totalCalls = models.reduce((s, m) => s + (m.totalCalls || 0), 0);
|
||||
const paidModels = models.filter(m => m.status === 'paid').length;
|
||||
|
||||
const items = [
|
||||
{ label: '供应商', value: providers.length, color: '#5B5FED', icon: '🔌' },
|
||||
{ label: '模型总数', value: models.length, color: '#219B5E', icon: '🤖' },
|
||||
{ label: '已启用', value: enabled, color: '#16A34A', icon: '✅' },
|
||||
{ label: '总调用量', value: formatNumber(totalCalls), color: '#E8922C', icon: '📊' },
|
||||
{ label: '默认模型', value: defaults, color: '#8B5CF6', icon: '⭐' },
|
||||
{ label: '付费模型', value: paidModels, color: '#EC4899', icon: '💎' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{
|
||||
background: '#FFFFFF', borderRadius: 10, border: '1px solid rgba(0,0,0,0.08)',
|
||||
padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>{item.label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Models: React.FC = () => {
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [models, setModels] = useState<AIModel[]>([]);
|
||||
const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchableModel[]>([]);
|
||||
const [fetchSource, setFetchSource] = useState<string>('');
|
||||
const [fetchMessage, setFetchMessage] = useState<string>('');
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(new Set());
|
||||
const [showAddProvider, setShowAddProvider] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
const [testingIds, setTestingIds] = useState<Set<string>>(new Set());
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
const [expandedTestResult, setExpandedTestResult] = useState<string | null>(null);
|
||||
const [showModelEditor, setShowModelEditor] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
|
||||
const [modelSaving, setModelSaving] = useState(false);
|
||||
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '',
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [provRes, modelsRes, protoRes] = await Promise.all([
|
||||
adminAPI.getProviders(),
|
||||
adminAPI.getAdminModels({ page_size: 100 }),
|
||||
adminAPI.getProtocols(),
|
||||
]);
|
||||
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
|
||||
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
|
||||
const modelData = modelsRes.data || modelsRes;
|
||||
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
|
||||
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
|
||||
...m,
|
||||
providerId: m.providerId || m.provider_id,
|
||||
displayName: m.displayName || m.display_name || m.name,
|
||||
apiKey: m.apiKey || m.api_key,
|
||||
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
|
||||
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
|
||||
isDefault: m.isDefault ?? m.is_default ?? false,
|
||||
totalCalls: m.totalCalls ?? m.total_calls ?? 0,
|
||||
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
|
||||
}));
|
||||
setModels(mappedModels);
|
||||
} catch (err: any) {
|
||||
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Filtered models
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelSearch.trim()) return models;
|
||||
const q = modelSearch.toLowerCase();
|
||||
return models.filter(m =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
((m as any).display_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [models, modelSearch]);
|
||||
|
||||
// Test single model connection
|
||||
const handleTestConnection = async (modelId: string) => {
|
||||
setTestingIds(prev => new Set(prev).add(modelId));
|
||||
try {
|
||||
const result = await adminAPI.testModelConnection(modelId) as any;
|
||||
const testResult: TestResult = result.success !== undefined ? result : (result.data || result as TestResult);
|
||||
setTestResults(prev => ({ ...prev, [modelId]: testResult }));
|
||||
} catch (err: any) {
|
||||
setTestResults(prev => ({ ...prev, [modelId]: { success: false, message: err.message || '测试失败' } }));
|
||||
} finally {
|
||||
setTestingIds(prev => { const next = new Set(prev); next.delete(modelId); return next; });
|
||||
}
|
||||
};
|
||||
|
||||
// Test all models
|
||||
const handleTestAll = async () => {
|
||||
setTestingAll(true);
|
||||
setTestResults({});
|
||||
try {
|
||||
const result = await adminAPI.testAllModels() as any;
|
||||
const data = result.data || result;
|
||||
const resultsMap: Record<string, TestResult> = {};
|
||||
if (data.results) {
|
||||
for (const r of data.results) {
|
||||
resultsMap[r.model_id] = {
|
||||
success: r.success,
|
||||
message: r.message,
|
||||
latency_ms: r.latency_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
setTestResults(resultsMap);
|
||||
} catch (err: any) {
|
||||
setError('批量测试失败: ' + (err.message || ''));
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get model type label
|
||||
const getModelType = (caps: string[] | undefined): string => {
|
||||
if (!caps || caps.length === 0) return 'text';
|
||||
const lower = caps.map(c => c.toLowerCase());
|
||||
if (lower.includes('image')) return 'image';
|
||||
if (lower.includes('audio')) return 'audio';
|
||||
if (lower.includes('video')) return 'video';
|
||||
if (lower.includes('vision')) return 'vision';
|
||||
return 'text';
|
||||
};
|
||||
|
||||
const modelTypeLabels: Record<string, { label: string; color: string }> = {
|
||||
text: { label: '文本', color: '#5B5FED' },
|
||||
image: { label: '图像生成', color: '#D9383A' },
|
||||
audio: { label: '音频', color: '#E8922C' },
|
||||
video: { label: '视频', color: '#EC4899' },
|
||||
vision: { label: '视觉理解', color: '#219B5E' },
|
||||
};
|
||||
|
||||
const handleSetDefault = async (modelId: string) => {
|
||||
try {
|
||||
await adminAPI.setDefaultModel(modelId);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProvider = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!providerForm.name.trim()) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.addProvider({
|
||||
name: providerForm.name, protocol: providerForm.protocol,
|
||||
api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined,
|
||||
});
|
||||
setShowAddProvider(false);
|
||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '添加供应商失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProvider = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingProvider || !providerForm.name.trim()) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.updateProvider(editingProvider.id, {
|
||||
name: providerForm.name, protocol: providerForm.protocol,
|
||||
api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined,
|
||||
});
|
||||
setEditingProvider(null);
|
||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新供应商失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
if (!confirm('确定删除该供应商?关联的模型也会受影响。')) return;
|
||||
try {
|
||||
await adminAPI.deleteProvider(id);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchModels = async (providerId: string) => {
|
||||
try {
|
||||
const result = await adminAPI.fetchProviderModels(providerId) as any;
|
||||
setFetchedModels(result.models || result.data?.models || []);
|
||||
setFetchSource(result.source || result.data?.source || 'unknown');
|
||||
setFetchMessage(result.message || result.data?.message || '');
|
||||
setExpandedProvider(providerId);
|
||||
setSelectedModelIds(new Set());
|
||||
} catch (err: any) {
|
||||
setError(err.message || '获取模型列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModels = async (providerId: string) => {
|
||||
if (selectedModelIds.size === 0) return;
|
||||
try {
|
||||
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
|
||||
setExpandedProvider(null);
|
||||
setSelectedModelIds(new Set());
|
||||
setFetchedModels([]);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '添加模型失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModelSelection = (id: string) => {
|
||||
const next = new Set(selectedModelIds);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSelectedModelIds(next);
|
||||
};
|
||||
|
||||
const openModelEditor = (model?: AIModel, providerId?: string) => {
|
||||
if (model) {
|
||||
setEditingModel({
|
||||
id: model.id, name: model.name,
|
||||
display_name: (model as any).display_name || (model as any).displayName || model.name,
|
||||
provider_id: (model as any).provider_id || model.providerId || providerId || '',
|
||||
api_key: (model as any).api_key || model.apiKey || '',
|
||||
endpoint: model.endpoint || '',
|
||||
capabilities: model.capabilities || ['text'],
|
||||
timeout: model.timeout || 60,
|
||||
status: model.status || 'paid',
|
||||
price_per_million_tokens: model.pricePerMillionTokens ?? 0,
|
||||
is_enabled: model.isEnabled ?? true,
|
||||
});
|
||||
} else {
|
||||
setEditingModel({
|
||||
name: '', display_name: '', provider_id: providerId || '',
|
||||
api_key: '', endpoint: '',
|
||||
capabilities: ['text'], timeout: 60,
|
||||
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
|
||||
});
|
||||
}
|
||||
setShowModelEditor(true);
|
||||
};
|
||||
|
||||
const handleSaveModel = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingModel || !editingModel.name.trim()) return;
|
||||
setModelSaving(true);
|
||||
try {
|
||||
if (editingModel.id) {
|
||||
await adminAPI.updateAdminModel(editingModel.id, editingModel as any);
|
||||
} else {
|
||||
await adminAPI.createAdminModel(editingModel as any);
|
||||
}
|
||||
setShowModelEditor(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存失败');
|
||||
} finally {
|
||||
setModelSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
if (!confirm('确定删除该模型?')) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminModel(modelId);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCapability = (cap: string) => {
|
||||
if (!editingModel) return;
|
||||
const caps = editingModel.capabilities || [];
|
||||
editingModel.capabilities = caps.includes(cap) ? caps.filter(c => c !== cap) : [...caps, cap];
|
||||
setEditingModel({ ...editingModel });
|
||||
};
|
||||
|
||||
const getProviderModels = (providerId: string) =>
|
||||
filteredModels.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#5A6480', fontSize: '13px' }}>
|
||||
管理 AI 模型供应商、模型配置和连接测试
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleTestAll}
|
||||
disabled={testingAll}
|
||||
style={btnStyle(testingAll ? '#4B5563' : '#059669')}>
|
||||
{testingAll ? '批量测试中...' : '⚡ 测试全部'}
|
||||
</button>
|
||||
<button onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
|
||||
style={btnStyle('#5B5FED')}>
|
||||
+ 添加供应商
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<ModelStatsPanel models={models} providers={providers} />
|
||||
|
||||
{error && (
|
||||
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
|
||||
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, maxWidth: 360 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text" placeholder="搜索模型名称..."
|
||||
value={modelSearch} onChange={e => setModelSearch(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px 8px 36px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '13px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
{modelSearch && (
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>
|
||||
找到 {filteredModels.length} 个模型
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: '#6B7280' }}>加载中...</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{providers.map(provider => {
|
||||
const provModels = getProviderModels(provider.id);
|
||||
const isExpanded = expandedProvider === provider.id;
|
||||
|
||||
return (
|
||||
<div key={provider.id} style={{ background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '16px 20px', background: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px' }}>{provider.name}</span>
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#EEF2FF', color: '#5B5FED', borderRadius: '4px' }}>
|
||||
{provider.protocol}
|
||||
</span>
|
||||
{provModels.filter(m => m.isDefault).length > 0 && (
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#DCFCE7', color: '#16A34A', borderRadius: '4px' }}>默认</span>
|
||||
)}
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF' }}>{provModels.length} 个模型</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#5A6480', marginTop: '4px' }}>
|
||||
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
|
||||
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => handleFetchModels(provider.id)} style={btnStyle('#6B7280', 'small')}>获取模型</button>
|
||||
<button onClick={() => { setEditingProvider(provider); setProviderForm({ name: provider.name, protocol: provider.protocol,
|
||||
api_base_url: (provider as any).api_base_url || provider.apiBaseUrl || '',
|
||||
api_key: (provider as any).api_key || provider.apiKey || '' }); setShowAddProvider(true); }} style={btnStyle('#6B7280', 'small')}>编辑</button>
|
||||
<button onClick={() => handleDeleteProvider(provider.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
|
||||
<button onClick={() => openModelEditor(undefined, provider.id)} style={btnStyle('#5B5FED', 'small')}>+ 添加模型</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#92400E' }}>
|
||||
{fetchMessage || `找到 ${fetchedModels.length} 个模型(来源: ${fetchSource})`}
|
||||
</span>
|
||||
{fetchedModels.length > 0 && (
|
||||
<button onClick={() => handleAddModels(provider.id)} style={btnStyle('#5B5FED', 'small')} disabled={selectedModelIds.size === 0}>
|
||||
添加选中 ({selectedModelIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{fetchedModels.map(m => (
|
||||
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer',
|
||||
padding: '4px 8px', background: selectedModelIds.has(m.id) ? 'rgba(108,99,255,0.15)' : 'rgba(17,22,39,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px' }}>
|
||||
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
|
||||
{m.name || m.id}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ padding: '0' }}>
|
||||
{provModels.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#6B7280', fontSize: '13px' }}>
|
||||
暂无模型,点击"获取模型"或"添加模型"添加
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F3F4F6' }}>
|
||||
<th style={thStyle}>模型名称</th>
|
||||
<th style={thStyle}>显示名称</th>
|
||||
<th style={thStyle}>类型</th>
|
||||
<th style={thStyle}>能力</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>价格</th>
|
||||
<th style={thStyle}>调用量</th>
|
||||
<th style={thStyle}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{provModels.map(model => (
|
||||
<tr key={model.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ fontWeight: 500 }}>{model.name}</div>
|
||||
<div style={{ fontSize: 11, color: '#9CA3AF', fontFamily: 'var(--font-mono)' }}>{model.id?.slice(0, 8)}...</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ fontSize: 13, color: '#374151' }}>
|
||||
{(model as any).display_name || (model as any).displayName || '-'}
|
||||
</span>
|
||||
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A', marginLeft: 6 }}>⭐ 默认</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{
|
||||
fontSize: '11px', padding: '2px 8px', borderRadius: '3px',
|
||||
background: (modelTypeLabels[getModelType(model.capabilities)].color) + '22',
|
||||
color: modelTypeLabels[getModelType(model.capabilities)].color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{modelTypeLabels[getModelType(model.capabilities)].label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{(model.capabilities || []).map(cap => (
|
||||
<span key={cap} style={{ fontSize: '11px', padding: '2px 6px', background: capabilityColors[cap] + '22', color: capabilityColors[cap], borderRadius: '3px' }}>
|
||||
{capabilityLabels[cap] || cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ fontSize: '12px', padding: '2px 8px', background: model.isEnabled ? '#DCFCE7' : '#FEE2E2', color: model.isEnabled ? '#16A34A' : '#DC2626', borderRadius: '4px' }}>
|
||||
{model.isEnabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ fontSize: '12px', color: '#5A6480' }}>
|
||||
¥{(model.pricePerMillionTokens || 0).toFixed(4)}/M
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#374151' }}>
|
||||
{formatNumber(model.totalCalls || 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#9CA3AF' }}>
|
||||
{formatNumber(model.totalTokens || 0)} tokens
|
||||
</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => handleTestConnection(model.id)} disabled={testingIds.has(model.id)}
|
||||
style={{ ...btnStyle(testingIds.has(model.id) ? '#4B5563' : '#059669', 'small') }}>
|
||||
{testingIds.has(model.id) ? '测试中...' : '测试'}
|
||||
</button>
|
||||
{testResults[model.id] && (
|
||||
<span
|
||||
onClick={() => setExpandedTestResult(expandedTestResult === model.id ? null : model.id)}
|
||||
style={{
|
||||
fontSize: '11px', padding: '4px 8px', cursor: 'pointer',
|
||||
background: testResults[model.id].success ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: testResults[model.id].success ? '#22C55E' : '#EF4444',
|
||||
borderRadius: '4px', border: `1px solid ${testResults[model.id].success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{testResults[model.id].success ? `✓ ${testResults[model.id].latency_ms || 0}ms` : '✗ 失败'}
|
||||
</span>
|
||||
)}
|
||||
{!model.isDefault && model.isEnabled && (
|
||||
<button onClick={() => handleSetDefault(model.id)} style={btnStyle('#5B5FED', 'small')}>设为默认</button>
|
||||
)}
|
||||
{expandedTestResult === model.id && testResults[model.id] && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}} onClick={() => setExpandedTestResult(null)}>
|
||||
<div style={{
|
||||
background: '#FFFFFF', borderRadius: 12, padding: 24,
|
||||
maxWidth: 480, width: '90%',
|
||||
border: `1px solid ${testResults[model.id].success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: '#E2E8F0' }}>连接测试详情</h3>
|
||||
<button onClick={() => setExpandedTestResult(null)} style={{ background: 'none', border: 'none', color: '#6B7280', cursor: 'pointer', fontSize: 18 }}>×</button>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>模型</span>
|
||||
<span style={{ fontSize: 14, color: '#E2E8F0', marginLeft: 8 }}>{model.name}</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>类型</span>
|
||||
<span style={{ fontSize: 14, color: modelTypeLabels[getModelType(model.capabilities)].color, marginLeft: 8 }}>
|
||||
{modelTypeLabels[getModelType(model.capabilities)].label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>状态</span>
|
||||
<span style={{
|
||||
fontSize: 14, marginLeft: 8, fontWeight: 600,
|
||||
color: testResults[model.id].success ? '#22C55E' : '#EF4444',
|
||||
}}>
|
||||
{testResults[model.id].success ? '✓ 成功' : '✗ 失败'}
|
||||
</span>
|
||||
</div>
|
||||
{testResults[model.id].latency_ms !== undefined && testResults[model.id].latency_ms !== null && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>延迟</span>
|
||||
<span style={{ fontSize: 14, color: '#E2E8F0', marginLeft: 8, fontFamily: 'monospace' }}>
|
||||
{testResults[model.id].latency_ms}ms
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280', display: 'block', marginBottom: 4 }}>详情</span>
|
||||
<div style={{
|
||||
fontSize: 13, color: '#9CA3AF', lineHeight: 1.6,
|
||||
background: 'rgba(0,0,0,0.3)', padding: 12, borderRadius: 8,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{testResults[model.id].message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => openModelEditor(model as AIModel)} style={btnStyle('#6B7280', 'small')}>编辑</button>
|
||||
<button onClick={() => handleDeleteModel(model.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{providers.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px', background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
|
||||
<p style={{ color: '#5A6480', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Modal */}
|
||||
{showAddProvider && (
|
||||
<div style={modalOverlay} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
|
||||
<div style={modalContent} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
|
||||
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>供应商名称 *</label>
|
||||
<input value={providerForm.name} onChange={e => setProviderForm({ ...providerForm, name: e.target.value })} placeholder="如:SiliconFlow" style={inputStyle} required />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>API 协议</label>
|
||||
<select value={providerForm.protocol} onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })} style={inputStyle}>
|
||||
{protocols.map(p => <option key={p.id} value={p.id}>{p.name} ({p.id})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>API Base URL</label>
|
||||
<input value={providerForm.api_base_url} onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })} placeholder="https://api.siliconflow.cn" style={inputStyle} />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>API Key</label>
|
||||
<input type="password" value={providerForm.api_key} onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })} placeholder="sk-..." style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button type="button" onClick={() => { setShowAddProvider(false); setEditingProvider(null); }} style={btnStyle('#6B7280')}>取消</button>
|
||||
<button type="submit" style={btnStyle('#5B5FED')}>{editingProvider ? '保存' : '添加'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Editor Modal */}
|
||||
{showModelEditor && editingModel && (
|
||||
<div style={modalOverlay} onClick={() => setShowModelEditor(false)}>
|
||||
<div style={{ ...modalContent, maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
|
||||
<form onSubmit={handleSaveModel}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>模型名称 *</label>
|
||||
<input value={editingModel.name} onChange={e => setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>显示名称</label>
|
||||
<input value={editingModel.display_name} onChange={e => setEditingModel({ ...editingModel, display_name: e.target.value })} placeholder="如:DeepSeek V4" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>供应商</label>
|
||||
<select value={editingModel.provider_id} onChange={e => setEditingModel({ ...editingModel, provider_id: e.target.value })} style={inputStyle}>
|
||||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>API Key</label>
|
||||
<input type="password" value={editingModel.api_key} onChange={e => setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>API Endpoint</label>
|
||||
<input value={editingModel.endpoint} onChange={e => setEditingModel({ ...editingModel, endpoint: e.target.value })} placeholder="https://api.siliconflow.cn/v1/chat/completions" style={inputStyle} />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>模型能力 (可多选)</label>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(capabilityLabels).map(([key, label]) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer',
|
||||
padding: '4px 10px', background: (editingModel.capabilities || []).includes(key) ? capabilityColors[key] + '22' : '#F3F4F6',
|
||||
border: `1px solid ${(editingModel.capabilities || []).includes(key) ? capabilityColors[key] : '#D1D5DB'}`,
|
||||
borderRadius: '4px', fontSize: '13px' }}>
|
||||
<input type="checkbox" checked={(editingModel.capabilities || []).includes(key)} onChange={() => toggleCapability(key)} style={{ display: 'none' }} />
|
||||
<span style={{ color: capabilityColors[key] }}>●</span> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>超时 (秒)</label>
|
||||
<input type="number" value={editingModel.timeout} onChange={e => setEditingModel({ ...editingModel, timeout: parseInt(e.target.value) || 60 })} style={inputStyle} />
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>计费状态</label>
|
||||
<select value={editingModel.status} onChange={e => setEditingModel({ ...editingModel, status: e.target.value })} style={inputStyle}>
|
||||
<option value="free">免费</option>
|
||||
<option value="paid">付费</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={labelStyle}>价格 (¥/M tokens)</label>
|
||||
<input type="number" step="0.0001" value={editingModel.price_per_million_tokens} onChange={e => setEditingModel({ ...editingModel, price_per_million_tokens: parseFloat(e.target.value) || 0 })} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={formGroup}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={editingModel.is_enabled} onChange={e => setEditingModel({ ...editingModel, is_enabled: e.target.checked })} />
|
||||
启用该模型
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button type="button" onClick={() => setShowModelEditor(false)} style={btnStyle('#6B7280')}>取消</button>
|
||||
<button type="submit" disabled={modelSaving} style={btnStyle('#5B5FED')}>{modelSaving ? '保存中...' : '保存'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Styles
|
||||
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
||||
background: color, color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer',
|
||||
fontSize: size === 'small' ? '12px' : '14px', padding: size === 'small' ? '4px 10px' : '8px 16px',
|
||||
fontWeight: 500, transition: 'opacity 0.2s', whiteSpace: 'nowrap' as const,
|
||||
});
|
||||
|
||||
const thStyle: React.CSSProperties = { padding: '10px 16px', textAlign: 'left', fontSize: '12px', color: '#6B7280', fontWeight: 600 };
|
||||
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px', verticalAlign: 'middle' };
|
||||
const formGroup: React.CSSProperties = { marginBottom: '16px' };
|
||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '6px', fontSize: '13px', fontWeight: 500, color: '#374151' };
|
||||
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' };
|
||||
const alertStyle = (bg: string, color: string): React.CSSProperties => ({ background: bg, color, padding: '10px 16px', borderRadius: '6px', fontSize: '13px' });
|
||||
const modalOverlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
|
||||
const modalContent: React.CSSProperties = { background: '#FFFFFF', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
|
||||
|
||||
export default Models;
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { PaymentConfig, Order } from '@/types';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, { cls: string; text: string }> = {
|
||||
active: { cls: 'badge-success', text: '已启用' },
|
||||
inactive: { cls: 'badge-neutral', text: '未启用' },
|
||||
paid: { cls: 'badge-success', text: '已支付' },
|
||||
pending: { cls: 'badge-warning', text: '待支付' },
|
||||
refunded: { cls: 'badge-error', text: '已退款' },
|
||||
};
|
||||
const s = map[status] || { cls: 'badge-neutral', text: status };
|
||||
return <span className={`admin-badge ${s.cls}`}>{s.text}</span>;
|
||||
};
|
||||
|
||||
const paymentMethodBadge = (method: string) => {
|
||||
return method === 'wechat'
|
||||
? <span className="admin-badge badge-success">微信支付</span>
|
||||
: <span className="admin-badge badge-info">支付宝</span>;
|
||||
};
|
||||
|
||||
const Payment: React.FC = () => {
|
||||
const [configs, setConfigs] = useState<PaymentConfig[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({});
|
||||
const [msg, setMsg] = useState<Record<string, { type: string; text: string }>>({});
|
||||
|
||||
const wechat = configs.find(c => c.type === 'wechat');
|
||||
const alipay = configs.find(c => c.type === 'alipay');
|
||||
|
||||
const [wechatForm, setWechatForm] = useState({
|
||||
app_id: '', merchant_id: '', api_key: '', api_v3_key: '',
|
||||
cert_path: '', cert_serial_no: '', notify_url: '',
|
||||
});
|
||||
|
||||
const [alipayForm, setAlipayForm] = useState({
|
||||
app_id: '', app_private_key: '', alipay_public_key: '',
|
||||
gateway_url: 'https://openapi.alipay.com/gateway.do', notify_url: '', return_url: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
loadOrders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (wechat) {
|
||||
setWechatForm({
|
||||
app_id: wechat.app_id || '',
|
||||
merchant_id: wechat.merchant_id || '',
|
||||
api_key: wechat.api_key || '',
|
||||
api_v3_key: wechat.api_v3_key || '',
|
||||
cert_path: wechat.cert_path || '/etc/certs/apiclient_cert.pem',
|
||||
cert_serial_no: wechat.cert_serial_no || '',
|
||||
notify_url: wechat.notify_url || '',
|
||||
});
|
||||
}
|
||||
if (alipay) {
|
||||
setAlipayForm({
|
||||
app_id: alipay.app_id || '',
|
||||
app_private_key: alipay.app_private_key || '',
|
||||
alipay_public_key: alipay.alipay_public_key || '',
|
||||
gateway_url: alipay.gateway_url || 'https://openapi.alipay.com/gateway.do',
|
||||
notify_url: alipay.notify_url || '',
|
||||
return_url: alipay.return_url || '',
|
||||
});
|
||||
}
|
||||
}, [configs]);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getPaymentConfigs();
|
||||
const list = res?.data || res;
|
||||
if (Array.isArray(list)) {
|
||||
setConfigs(list);
|
||||
}
|
||||
} catch (e) { console.error('加载支付配置失败', e); }
|
||||
};
|
||||
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAdminOrders({ page: 1, page_size: 20 });
|
||||
const data: any = res?.data || res;
|
||||
if (data?.items) {
|
||||
setOrders(data.items);
|
||||
} else if (Array.isArray(data)) {
|
||||
setOrders(data);
|
||||
}
|
||||
} catch (e) { console.error('加载订单失败', e); }
|
||||
};
|
||||
|
||||
const handleSaveConfig = async (id: string, data: any) => {
|
||||
setSaving(prev => ({ ...prev, [id]: true }));
|
||||
setMsg(prev => ({ ...prev, [id]: { type: '', text: '' } }));
|
||||
try {
|
||||
await adminAPI.updatePaymentConfig(id, data);
|
||||
setMsg(prev => ({ ...prev, [id]: { type: 'success', text: '保存成功' } }));
|
||||
loadConfigs();
|
||||
} catch (e: any) {
|
||||
setMsg(prev => ({ ...prev, [id]: { type: 'error', text: e.message || '保存失败' } }));
|
||||
} finally {
|
||||
setSaving(prev => ({ ...prev, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleConfig = async (type: string) => {
|
||||
const config = type === 'wechat' ? wechat : alipay;
|
||||
if (!config) return;
|
||||
const newStatus = config.status === 'active' ? 'inactive' : 'active';
|
||||
await handleSaveConfig(config.id, { status: newStatus });
|
||||
};
|
||||
|
||||
const handleRefund = async (orderId: string) => {
|
||||
try {
|
||||
await adminAPI.refundOrder(orderId);
|
||||
loadOrders();
|
||||
} catch (e) { console.error('退款失败', e); }
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<h1 className="admin-page-title">支付渠道配置</h1>
|
||||
|
||||
{Object.entries(msg).map(([id, m]) => m.text ? (
|
||||
<div key={id} style={{
|
||||
padding: 'var(--space-md) var(--space-lg)',
|
||||
marginBottom: 'var(--space-lg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: m.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
|
||||
color: m.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{m.text}
|
||||
</div>
|
||||
) : null)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
|
||||
{/* WeChat Pay Card */}
|
||||
<div className="admin-card" style={{ marginBottom: 0 }}>
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#E6F7EE', color: '#09BB07', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
|
||||
</div>
|
||||
微信支付
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
{wechat ? statusBadge(wechat.status) : <span className="admin-badge badge-neutral">未配置</span>}
|
||||
{wechat && (
|
||||
<button
|
||||
onClick={() => handleToggleConfig('wechat')}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, border: 'none',
|
||||
background: wechat.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: wechat.status === 'active' ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
|
||||
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">AppID</label>
|
||||
<input className="admin-form-input" value={wechatForm.app_id} onChange={e => setWechatForm({ ...wechatForm, app_id: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">商户号</label>
|
||||
<input className="admin-form-input" value={wechatForm.merchant_id} onChange={e => setWechatForm({ ...wechatForm, merchant_id: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">API v2 密钥</label>
|
||||
<input className="admin-form-input" type="password" value={wechatForm.api_key} onChange={e => setWechatForm({ ...wechatForm, api_key: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">API v3 密钥</label>
|
||||
<input className="admin-form-input" type="password" value={wechatForm.api_v3_key} onChange={e => setWechatForm({ ...wechatForm, api_v3_key: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">证书路径</label>
|
||||
<input className="admin-form-input" value={wechatForm.cert_path} onChange={e => setWechatForm({ ...wechatForm, cert_path: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">回调通知地址</label>
|
||||
<input className="admin-form-input" value={wechatForm.notify_url} placeholder="https://your-domain.com/api/payment/wechat/notify" onChange={e => setWechatForm({ ...wechatForm, notify_url: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="admin-btn admin-btn-primary" disabled={saving[wechat?.id || ''] || !wechat} onClick={() => wechat && handleSaveConfig(wechat.id, {
|
||||
app_id: wechatForm.app_id,
|
||||
merchant_id: wechatForm.merchant_id,
|
||||
api_key: wechatForm.api_key,
|
||||
api_v3_key: wechatForm.api_v3_key,
|
||||
cert_path: wechatForm.cert_path,
|
||||
notify_url: wechatForm.notify_url,
|
||||
})}>
|
||||
{saving[wechat?.id || ''] ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alipay Card */}
|
||||
<div className="admin-card" style={{ marginBottom: 0 }}>
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#EBF2FE', color: '#1677FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
|
||||
</div>
|
||||
支付宝
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
{alipay ? statusBadge(alipay.status) : <span className="admin-badge badge-neutral">未配置</span>}
|
||||
{alipay && (
|
||||
<button
|
||||
onClick={() => handleToggleConfig('alipay')}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, border: 'none',
|
||||
background: alipay.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: alipay.status === 'active' ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
|
||||
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">AppID</label>
|
||||
<input className="admin-form-input" value={alipayForm.app_id} onChange={e => setAlipayForm({ ...alipayForm, app_id: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">接口地址</label>
|
||||
<input className="admin-form-input" value={alipayForm.gateway_url} onChange={e => setAlipayForm({ ...alipayForm, gateway_url: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">应用私钥</label>
|
||||
<input className="admin-form-input" type="password" value={alipayForm.app_private_key} placeholder="-----BEGIN RSA PRIVATE KEY-----" onChange={e => setAlipayForm({ ...alipayForm, app_private_key: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">支付宝公钥</label>
|
||||
<input className="admin-form-input" type="password" value={alipayForm.alipay_public_key} placeholder="-----BEGIN PUBLIC KEY-----" onChange={e => setAlipayForm({ ...alipayForm, alipay_public_key: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">异步回调地址</label>
|
||||
<input className="admin-form-input" value={alipayForm.notify_url} placeholder="https://your-domain.com/api/payment/alipay/notify" onChange={e => setAlipayForm({ ...alipayForm, notify_url: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">同步跳转地址</label>
|
||||
<input className="admin-form-input" value={alipayForm.return_url} placeholder="https://your-domain.com/#/payment-result" onChange={e => setAlipayForm({ ...alipayForm, return_url: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="admin-btn admin-btn-primary" disabled={saving[alipay?.id || ''] || !alipay} onClick={() => alipay && handleSaveConfig(alipay.id, {
|
||||
app_id: alipayForm.app_id,
|
||||
app_private_key: alipayForm.app_private_key,
|
||||
alipay_public_key: alipayForm.alipay_public_key,
|
||||
gateway_url: alipayForm.gateway_url,
|
||||
notify_url: alipayForm.notify_url,
|
||||
return_url: alipayForm.return_url,
|
||||
})}>
|
||||
{saving[alipay?.id || ''] ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Management */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">订单管理</h3>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm">
|
||||
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>项目</th>
|
||||
<th>金额</th>
|
||||
<th>支付方式</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{order.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{order.userId}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${order.orderType === 'membership' ? 'badge-info' : order.orderType === 'app' ? 'badge-success' : 'badge-warning'}`}>
|
||||
{order.orderType === 'membership' ? '会员' : order.orderType === 'app' ? '应用' : '服务'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{order.itemName}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{order.amount.toFixed(2)} 元</td>
|
||||
<td>{paymentMethodBadge(order.paymentMethod)}</td>
|
||||
<td>{statusBadge(order.status)}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(order.createdAt)}</td>
|
||||
<td>
|
||||
{order.status === 'paid' && (
|
||||
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleRefund(order.id)}>
|
||||
退款
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'refunded' && (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>已退款</span>
|
||||
)}
|
||||
{order.status === 'pending' && (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>等待支付</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payment;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,520 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* 推广系统管理页面 — 使用 admin 设计系统
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface Promotion {
|
||||
id: number; title: string; description: string; reward_type: string; reward_value: number;
|
||||
min_payout: number; start_date: string; end_date: string; status: string; max_referrals: number;
|
||||
created_at: string; updated_at: string; link_count: number; total_rewards: number;
|
||||
}
|
||||
|
||||
interface ReferralLink {
|
||||
id: number; code: string; user_id: number; username: string; promotion_id: number;
|
||||
promotion_title: string; target_url: string; channel: string; total_clicks: number;
|
||||
total_registrations: number; total_conversions: number; status: string; created_at: string;
|
||||
}
|
||||
|
||||
interface ReferralReward {
|
||||
id: number; referral_code: string; referrer_name: string; referee_name: string;
|
||||
order_id: string; reward_type: string; reward_amount: number; reward_points: number;
|
||||
status: string; payout_method: string; paid_at: string; created_at: string;
|
||||
}
|
||||
|
||||
const Promotion: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'promotions' | 'links' | 'rewards' | 'leaderboard'>('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [promotions, setPromotions] = useState<Promotion[]>([]);
|
||||
const [referralLinks, setReferralLinks] = useState<ReferralLink[]>([]);
|
||||
const [rewards, setRewards] = useState<ReferralReward[]>([]);
|
||||
const [leaderboard, setLeaderboard] = useState<any[]>([]);
|
||||
|
||||
const [promoPage, setPromoPage] = useState(1);
|
||||
const [totalPromos, setTotalPromos] = useState(0);
|
||||
const [showPromoModal, setShowPromoModal] = useState(false);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [editingPromo, setEditingPromo] = useState<Promotion | null>(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getPromotionStats();
|
||||
const data = (res as any)?.data || res || {};
|
||||
setStats(data);
|
||||
} catch (err) { console.error('Stats load failed:', err); }
|
||||
}, []);
|
||||
|
||||
const loadPromotions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getPromotions({ page: promoPage, page_size: 20 });
|
||||
const data = (res as any)?.data || res || {};
|
||||
setPromotions(data.items || []);
|
||||
setTotalPromos(data.total || 0);
|
||||
} catch (err) { setError('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [promoPage]);
|
||||
|
||||
const loadLinks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getReferralLinks({ page: 1, page_size: 50 });
|
||||
const data = (res as any)?.data || res || {};
|
||||
setReferralLinks(data.items || []);
|
||||
} catch (err) { setError('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadRewards = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getReferralRewards({ page: 1, page_size: 50 });
|
||||
const data = (res as any)?.data || res || {};
|
||||
setRewards(data.items || []);
|
||||
} catch (err) { setError('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadLeaderboard = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getPromotionLeaderboard({ limit: 50 });
|
||||
const data = (res as any)?.data || res || {};
|
||||
setLeaderboard(data.items || data.leaderboard || []);
|
||||
} catch (err) { console.error('Leaderboard load failed:', err); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
if (activeTab === 'overview') loadStats();
|
||||
else if (activeTab === 'promotions') loadPromotions();
|
||||
else if (activeTab === 'links') loadLinks();
|
||||
else if (activeTab === 'rewards') loadRewards();
|
||||
else if (activeTab === 'leaderboard') loadLeaderboard();
|
||||
}, [activeTab, loadStats, loadPromotions, loadLinks, loadRewards, loadLeaderboard]);
|
||||
|
||||
const handleSavePromo = async (formData: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingPromo) await adminAPI.updatePromotion(editingPromo.id, formData);
|
||||
else await adminAPI.createPromotion(formData);
|
||||
setShowPromoModal(false); setEditingPromo(null); loadPromotions(); loadStats();
|
||||
} catch (err) { setError('保存失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleTogglePromo = async (id: number) => {
|
||||
try { await adminAPI.togglePromotion(id); loadPromotions(); }
|
||||
catch (err) { setError('操作失败'); }
|
||||
};
|
||||
|
||||
const handleDeletePromo = async (id: number) => {
|
||||
if (!window.confirm('确定删除此推广活动?')) return;
|
||||
try { await adminAPI.deletePromotion(id); loadPromotions(); loadStats(); }
|
||||
catch (err) { setError('删除失败'); }
|
||||
};
|
||||
|
||||
const handleGenerateLink = async (formData: any) => {
|
||||
setLoading(true);
|
||||
try { await adminAPI.generateReferralLink(formData); setShowLinkModal(false); loadLinks(); loadStats(); }
|
||||
catch (err) { setError('生成失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleApproveReward = async (id: number) => {
|
||||
try { await adminAPI.approveReward(id); loadRewards(); }
|
||||
catch (err) { setError('操作失败'); }
|
||||
};
|
||||
|
||||
const handleMarkPaid = async (id: number) => {
|
||||
const payout_method = prompt('请输入提现方式(wechat/alipay/bank/balance):');
|
||||
if (!payout_method) return;
|
||||
try {
|
||||
await adminAPI.markRewardPaid(id, { payout_method, payout_transaction_id: `TXN${Date.now()}` });
|
||||
loadRewards();
|
||||
} catch (err) { setError('操作失败'); }
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
|
||||
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: '概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
|
||||
{ key: 'promotions', label: '推广活动', icon: <><path d="M3 11l18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></> },
|
||||
{ key: 'links', label: '分销链接', icon: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></> },
|
||||
{ key: 'rewards', label: '返佣记录', icon: <><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></> },
|
||||
{ key: 'leaderboard', label: '排行榜', icon: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||
<div>
|
||||
<h1 className="admin-page-title">推广系统管理</h1>
|
||||
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>管理推广活动、分销链接、返佣记录和推广统计</p>
|
||||
</div>
|
||||
{activeTab === 'promotions' && (
|
||||
<button className="admin-btn admin-btn-primary" onClick={() => { setEditingPromo(null); setShowPromoModal(true); }}>
|
||||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
创建活动
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'links' && (
|
||||
<button className="admin-btn admin-btn-primary" onClick={() => setShowLinkModal(true)}>
|
||||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
生成链接
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div style={{ padding: '8px 16px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-md)', fontSize: 'var(--text-sm)' }}>{error}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key as any)}
|
||||
style={{
|
||||
padding: '10px 20px', border: 'none', cursor: 'pointer',
|
||||
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
|
||||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<SVGIcon size={14}>{tab.icon}</SVGIcon>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ===================== Overview Tab ===================== */}
|
||||
{activeTab === 'overview' && (
|
||||
<div>
|
||||
{!stats ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="responsive-grid-4" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
{[
|
||||
{ label: '推广活动总数', value: stats.total_promotions || 0, color: '#818cf8', icon: 'M3 11l18-5v12L3 14v-3z' },
|
||||
{ label: '进行中的活动', value: stats.active_promotions || 0, color: '#22c55e', icon: 'M9 12l2 2 4-4' },
|
||||
{ label: '分销链接总数', value: stats.total_referrals || stats.total_referral_links || 0, color: '#f59e0b', icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' },
|
||||
{ label: '已发返佣总额', value: `¥${(stats.total_rewards || stats.total_rewards_paid || 0).toFixed(2)}`, color: '#06b6d4', icon: 'M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 'var(--space-sm)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-full)', background: `${s.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<SVGIcon size={20}><path d={s.icon}/></SVGIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header"><h3 className="admin-card-title">待审核返佣</h3></div>
|
||||
<div style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 800, color: '#f59e0b' }}>¥{(stats.pending_rewards || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>待审核返佣金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Promotions Tab ===================== */}
|
||||
{activeTab === 'promotions' && (
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>活动标题</th><th>返佣类型</th><th>返佣值</th>
|
||||
<th>状态</th><th>链接数</th><th>已发返佣</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{promotions.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||||
) : promotions.map(promo => (
|
||||
<tr key={promo.id}>
|
||||
<td>{promo.id}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{promo.title}</div>
|
||||
{promo.description && <div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{promo.description}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
|
||||
{promo.reward_type === 'percentage' ? '百分比' : promo.reward_type === 'fixed' ? '固定金额' : '积分'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{promo.reward_type === 'percentage' ? `${promo.reward_value}%` :
|
||||
promo.reward_type === 'fixed' ? `¥${promo.reward_value?.toFixed(2)}` : `${promo.reward_value} 分`}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${promo.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{promo.status === 'active' ? '进行中' : promo.status === 'inactive' ? '已停用' : '已过期'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{promo.link_count || 0}</td>
|
||||
<td>¥{(promo.total_rewards || 0).toFixed(2)}</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)' }}>{promo.created_at ? new Date(promo.created_at).toLocaleDateString('zh-CN') : '-'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setEditingPromo(promo); setShowPromoModal(true); }}>编辑</button>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleTogglePromo(promo.id)}>
|
||||
{promo.status === 'active' ? '停用' : '启用'}
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDeletePromo(promo.id)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPromos > 20 && (
|
||||
<div className="pagination-bar" style={{ marginTop: 'var(--space-md)' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage <= 1} onClick={() => setPromoPage(p => p - 1)}>上一页</button>
|
||||
<span style={{ fontSize: 'var(--text-sm)' }}>第 {promoPage} 页 / 共 {Math.ceil(totalPromos / 20)} 页</span>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage >= Math.ceil(totalPromos / 20)} onClick={() => setPromoPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Links Tab ===================== */}
|
||||
{activeTab === 'links' && (
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>推广短码</th><th>推广人</th><th>关联活动</th>
|
||||
<th>点击数</th><th>注册数</th><th>转化数</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{referralLinks.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||||
) : referralLinks.map(link => (
|
||||
<tr key={link.id}>
|
||||
<td>{link.id}</td>
|
||||
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{link.code}</code></td>
|
||||
<td>{link.username}</td>
|
||||
<td>{link.promotion_title || '-'}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_clicks || 0}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_registrations || 0}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_conversions || 0}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${link.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{link.status === 'active' ? '活跃' : '停用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => {
|
||||
const url = `${link.target_url}?ref=${link.code}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
alert('链接已复制');
|
||||
}}>复制链接</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Rewards Tab ===================== */}
|
||||
{activeTab === 'rewards' && (
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>推广码</th><th>推广人</th><th>被推荐人</th>
|
||||
<th>返佣类型</th><th>返佣金额</th><th>状态</th><th>打款时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewards.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||||
) : rewards.map(reward => (
|
||||
<tr key={reward.id}>
|
||||
<td>{reward.id}</td>
|
||||
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{reward.referral_code}</code></td>
|
||||
<td>{reward.referrer_name}</td>
|
||||
<td>{reward.referee_name}</td>
|
||||
<td>
|
||||
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
|
||||
{reward.reward_type === 'registration' ? '注册奖励' : reward.reward_type === 'first_purchase' ? '首购返佣' : '续费返佣'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{reward.reward_amount?.toFixed(2)}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${reward.status === 'paid' ? 'badge-success' : reward.status === 'approved' ? 'badge-success' : reward.status === 'pending' ? 'badge-neutral' : 'badge-danger'}`}>
|
||||
{reward.status === 'pending' ? '待审核' : reward.status === 'approved' ? '已通过' : reward.status === 'paid' ? '已打款' : '已取消'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)' }}>{reward.paid_at ? new Date(reward.paid_at).toLocaleDateString('zh-CN') : '-'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{reward.status === 'pending' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleApproveReward(reward.id)}>审核通过</button>}
|
||||
{reward.status === 'approved' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleMarkPaid(reward.id)}>标记已打款</button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Leaderboard Tab ===================== */}
|
||||
{activeTab === 'leaderboard' && (
|
||||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>排名</th><th>用户</th><th>点击数</th><th>注册数</th><th>转化数</th><th>产生收入</th><th>获得返佣</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leaderboard.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||||
) : leaderboard.map((entry, index) => (
|
||||
<tr key={entry.user_id || index}>
|
||||
<td>
|
||||
<span style={{ fontSize: 'var(--text-lg)' }}>{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{entry.username}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_clicks || 0}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_registrations || 0}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_conversions || 0}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_revenue || 0).toFixed(2)}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_rewards || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================== Promotion Modal ===================== */}
|
||||
{showPromoModal && (
|
||||
<PromotionModal promotion={editingPromo} onSave={handleSavePromo} onCancel={() => { setShowPromoModal(false); setEditingPromo(null); }} />
|
||||
)}
|
||||
|
||||
{/* ===================== Link Modal ===================== */}
|
||||
{showLinkModal && (
|
||||
<LinkModal onSave={handleGenerateLink} onCancel={() => setShowLinkModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 推广活动弹窗 ====================
|
||||
const PromotionModal: React.FC<{ promotion: Promotion | null; onSave: (data: any) => void; onCancel: () => void }> = ({ promotion, onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: promotion?.title || '', description: promotion?.description || '',
|
||||
reward_type: promotion?.reward_type || 'percentage', reward_value: promotion?.reward_value || 5,
|
||||
min_payout: promotion?.min_payout || 10, start_date: promotion?.start_date ? promotion.start_date.slice(0, 16) : '',
|
||||
end_date: promotion?.end_date ? promotion.end_date.slice(0, 16) : '', max_referrals: promotion?.max_referrals || 0,
|
||||
status: promotion?.status || 'active',
|
||||
});
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
|
||||
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '4vh' }} onClick={onCancel}>
|
||||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 600, maxHeight: '90vh', overflow: 'auto', boxShadow: 'var(--shadow-xl)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>{promotion ? '编辑推广活动' : '创建推广活动'}</h3>
|
||||
</div>
|
||||
<form onSubmit={e => { e.preventDefault(); onSave(formData); }}>
|
||||
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>活动标题 *</label><input style={inputStyle} required value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} placeholder="例如:新用户推广计划" /></div>
|
||||
<div><label style={labelStyle}>活动描述</label><textarea style={{ ...inputStyle, resize: 'vertical' }} value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} rows={3} placeholder="活动说明..." /></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>返佣类型 *</label><select style={inputStyle} value={formData.reward_type} onChange={e => setFormData({ ...formData, reward_type: e.target.value })}><option value="percentage">百分比</option><option value="fixed">固定金额</option><option value="points">积分</option></select></div>
|
||||
<div><label style={labelStyle}>返佣值 *</label><input type="number" style={inputStyle} required step={formData.reward_type === 'percentage' ? 1 : 0.01} min={0} value={formData.reward_value} onChange={e => setFormData({ ...formData, reward_value: parseFloat(e.target.value) })} /></div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>最低提现金额</label><input type="number" style={inputStyle} step={0.01} min={0} value={formData.min_payout} onChange={e => setFormData({ ...formData, min_payout: parseFloat(e.target.value) })} /></div>
|
||||
<div><label style={labelStyle}>最大推广人数 (0=不限)</label><input type="number" style={inputStyle} min={0} value={formData.max_referrals} onChange={e => setFormData({ ...formData, max_referrals: parseInt(e.target.value) })} /></div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>开始时间 *</label><input type="datetime-local" style={inputStyle} required value={formData.start_date} onChange={e => setFormData({ ...formData, start_date: e.target.value })} /></div>
|
||||
<div><label style={labelStyle}>结束时间 (可选)</label><input type="datetime-local" style={inputStyle} value={formData.end_date} onChange={e => setFormData({ ...formData, end_date: e.target.value })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', borderTop: '1px solid var(--color-border)' }}>
|
||||
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
|
||||
<button type="submit" className="admin-btn admin-btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 生成链接弹窗 ====================
|
||||
const LinkModal: React.FC<{ onSave: (data: any) => void; onCancel: () => void }> = ({ onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState({ promotion_id: '', target_url: 'https://bot.qihuyun.com/register', channel: 'link' });
|
||||
const [promotions, setPromotions] = useState<Promotion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
adminAPI.getPromotions({ status: 'active', page: 1, page_size: 100 })
|
||||
.then(res => { const d = (res as any)?.data || res; if (d?.items) setPromotions(d.items); })
|
||||
.catch(err => console.error('Failed to load promotions:', err));
|
||||
}, []);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
|
||||
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onCancel}>
|
||||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>生成分销链接</h3>
|
||||
<form onSubmit={e => { e.preventDefault(); onSave({ promotion_id: parseInt(formData.promotion_id), target_url: formData.target_url, channel: formData.channel }); }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||||
<div><label style={labelStyle}>选择推广活动 *</label><select style={inputStyle} required value={formData.promotion_id} onChange={e => setFormData({ ...formData, promotion_id: e.target.value })}><option value="">-- 请选择 --</option>{promotions.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}</select></div>
|
||||
<div><label style={labelStyle}>目标URL *</label><input type="url" style={inputStyle} required value={formData.target_url} onChange={e => setFormData({ ...formData, target_url: e.target.value })} /></div>
|
||||
<div><label style={labelStyle}>推广渠道</label><select style={inputStyle} value={formData.channel} onChange={e => setFormData({ ...formData, channel: e.target.value })}><option value="link">链接</option><option value="qrcode">二维码</option><option value="api">API</option></select></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
|
||||
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
|
||||
<button type="submit" className="admin-btn admin-btn-primary">生成链接</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Promotion;
|
||||
@@ -0,0 +1,546 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SERVICE_TYPE_MAP: Record<string, { label: string; color: string; bg: string }> = {
|
||||
app_custom: { label: '应用定制', color: '#4F46E5', bg: '#EEF2FF' },
|
||||
platform_proxy: { label: '平台代理', color: '#059669', bg: '#ECFDF5' },
|
||||
data_custom: { label: '数据定制', color: '#D97706', bg: '#FFFBEB' },
|
||||
private_project: { label: '私有化项目', color: '#7C3AED', bg: '#F5F3FF' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '上架中', cls: 'badge-success' },
|
||||
inactive: { label: '已下架', cls: 'badge-neutral' },
|
||||
};
|
||||
|
||||
const REQUEST_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: '待处理', cls: 'badge-warning' },
|
||||
processing: { label: '处理中', cls: 'badge-info' },
|
||||
completed: { label: '已完成', cls: 'badge-success' },
|
||||
rejected: { label: '已驳回', cls: 'badge-error' },
|
||||
};
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
type_name: string;
|
||||
features: string[];
|
||||
status: string;
|
||||
assigned_agent_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CustomRequestItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
request_type: string;
|
||||
request_type_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requirements: Record<string, any>;
|
||||
status: string;
|
||||
assigned_agent_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
publisher_name: string;
|
||||
}
|
||||
|
||||
const Services: React.FC = () => {
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [requests, setRequests] = useState<CustomRequestItem[]>([]);
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'services' | 'requests'>('services');
|
||||
|
||||
// Modal states
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingService, setEditingService] = useState<ServiceItem | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '', service_type: 'app_custom', description: '', price: 0,
|
||||
features: '', status: 'active', assigned_agent_id: '',
|
||||
});
|
||||
|
||||
// Agent assignment modal
|
||||
const [showAgentModal, setShowAgentModal] = useState(false);
|
||||
const [agentServiceId, setAgentServiceId] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [svcRes, reqRes, agentsRes] = await Promise.all([
|
||||
adminAPI.getServices(),
|
||||
adminAPI.getCustomRequests(),
|
||||
adminAPI.getAvailableAgents(),
|
||||
]);
|
||||
if (Array.isArray(svcRes)) setServices(svcRes);
|
||||
else if (svcRes?.data) setServices(svcRes.data);
|
||||
if (Array.isArray(reqRes)) setRequests(reqRes);
|
||||
else if (reqRes?.data) setRequests(reqRes.data);
|
||||
if (Array.isArray(agentsRes)) setAgents(agentsRes);
|
||||
else if (agentsRes?.data) setAgents(agentsRes.data);
|
||||
} catch (e) {
|
||||
console.error('加载增值服务数据失败', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleEdit = (s: ServiceItem) => {
|
||||
setEditingService(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
service_type: s.type,
|
||||
description: s.description || '',
|
||||
price: s.price || 0,
|
||||
features: (s.features || []).join(', '),
|
||||
status: s.status,
|
||||
assigned_agent_id: s.assigned_agent_id || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
try {
|
||||
const data = {
|
||||
name: form.name,
|
||||
service_type: form.service_type,
|
||||
description: form.description,
|
||||
price: form.price,
|
||||
features: form.features,
|
||||
status: form.status,
|
||||
assigned_agent_id: form.assigned_agent_id || undefined,
|
||||
};
|
||||
if (editingService) {
|
||||
await adminAPI.updateService(editingService.id, data);
|
||||
} else {
|
||||
await adminAPI.addService(data);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingService(null);
|
||||
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert('保存失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm('确定要删除服务: ' + name + '? 删除后该服务将不再对外展示,但不会影响已发生的服务记录。')) return;
|
||||
try {
|
||||
console.log('[handleDelete] 开始删除服务:', id, name);
|
||||
const res = await adminAPI.deleteService(id);
|
||||
console.log('[handleDelete] 删除响应:', res);
|
||||
alert(res?.message || '删除成功');
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
console.error('[handleDelete] 删除失败:', e);
|
||||
let msg = '未知错误';
|
||||
if (typeof e === 'string') {
|
||||
msg = e;
|
||||
} else if (e?.response?.data?.detail) {
|
||||
msg = e.response.data.detail;
|
||||
} else if (e?.detail) {
|
||||
msg = e.detail;
|
||||
} else if (e?.message) {
|
||||
msg = e.message;
|
||||
}
|
||||
alert('删除失败: ' + msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (s: ServiceItem) => {
|
||||
try {
|
||||
await adminAPI.updateService(s.id, { status: s.status === 'active' ? 'inactive' : 'active' });
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert('操作失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAgent = async (agentId: string) => {
|
||||
try {
|
||||
await adminAPI.assignAgentToService(agentServiceId, agentId);
|
||||
alert('Agent绑定成功');
|
||||
setShowAgentModal(false);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert('绑定失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRequest = async (id: string, status: string) => {
|
||||
try {
|
||||
await adminAPI.updateCustomRequest(id, { status });
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert('更新失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAgentToRequest = async (id: string, agentId: string) => {
|
||||
try {
|
||||
await adminAPI.updateCustomRequest(id, { assigned_agent_id: agentId, status: 'processing' });
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert('分配失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
if (!d) return '--';
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 60 }}>加载中...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>增值服务管理</h1>
|
||||
<button className="admin-btn admin-btn-primary" onClick={() => {
|
||||
setEditingService(null);
|
||||
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
|
||||
setShowModal(true);
|
||||
}}>
|
||||
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
上架新服务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border-light)', paddingBottom: 'var(--space-md)' }}>
|
||||
<button
|
||||
className={`pricing-cycle-tab ${activeTab === 'services' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('services')}
|
||||
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'services' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'services' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
服务列表 ({services.length})
|
||||
</button>
|
||||
<button
|
||||
className={`pricing-cycle-tab ${activeTab === 'requests' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'requests' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'requests' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
工单管理 ({requests.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Services List */}
|
||||
{activeTab === 'services' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">增值服务列表</h3>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>价格</th>
|
||||
<th>绑定Agent</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map(s => {
|
||||
const typeConfig = SERVICE_TYPE_MAP[s.type] || SERVICE_TYPE_MAP.app_custom;
|
||||
const statusConfig = STATUS_MAP[s.status] || STATUS_MAP.active;
|
||||
const boundAgent = s.assigned_agent_id ? agents.find(a => a.id === s.assigned_agent_id) : null;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.description}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
|
||||
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
|
||||
}}>
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
|
||||
{s.price > 0 ? s.price.toFixed(2) + ' 元' : '面议'}
|
||||
</td>
|
||||
<td>
|
||||
{boundAgent ? (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>
|
||||
{boundAgent.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未绑定</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(s)}>
|
||||
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
|
||||
编辑
|
||||
</button>
|
||||
{s.type === 'app_custom' && (
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setAgentServiceId(s.id); setShowAgentModal(true); }}>
|
||||
<SVGIcon size={12}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon>
|
||||
Agent
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`admin-btn ${s.status === 'active' ? 'admin-btn-danger' : 'admin-btn-outline'} admin-btn-sm`}
|
||||
onClick={() => handleToggleStatus(s)}
|
||||
>
|
||||
{s.status === 'active' ? '下架' : '上架'}
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleDelete(s.id, s.name)}>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无增值服务</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CustomRequests List */}
|
||||
{activeTab === 'requests' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">服务工单列表</h3>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工单号</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>标题</th>
|
||||
<th>状态</th>
|
||||
<th>分配Agent</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map(req => {
|
||||
const typeConfig = SERVICE_TYPE_MAP[req.request_type] || SERVICE_TYPE_MAP.app_custom;
|
||||
const statusConfig = REQUEST_STATUS_MAP[req.status] || REQUEST_STATUS_MAP.pending;
|
||||
return (
|
||||
<tr key={req.id}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{req.id.slice(0, 8)}...</td>
|
||||
<td style={{ fontWeight: 500 }}>{req.username}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
|
||||
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
|
||||
}}>
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{req.title}</td>
|
||||
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
|
||||
<td>
|
||||
{req.assigned_agent_id ? (
|
||||
(() => {
|
||||
const agent = agents.find(a => a.id === req.assigned_agent_id);
|
||||
return <span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>{agent ? agent.name : req.assigned_agent_id.slice(0, 8)}</span>;
|
||||
})()
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未分配</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(req.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
|
||||
{req.status === 'pending' && (
|
||||
<>
|
||||
<select
|
||||
style={{ fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)' }}
|
||||
defaultValue=""
|
||||
onChange={e => {
|
||||
if (e.target.value) handleAssignAgentToRequest(req.id, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">分配Agent</option>
|
||||
{agents.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'rejected')}>
|
||||
驳回
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{req.status === 'processing' && (
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'completed')}>
|
||||
标记完成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{requests.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无服务工单</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadeIn var(--transition-normal)',
|
||||
}} onClick={() => { setShowModal(false); setEditingService(null); }}>
|
||||
<div style={{
|
||||
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
||||
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
|
||||
{editingService ? '编辑服务' : '上架新服务'}
|
||||
</h3>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">服务名称</label>
|
||||
<input className="admin-form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="输入服务名称" />
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">服务类型</label>
|
||||
<select className="admin-form-input" value={form.service_type} onChange={e => setForm({ ...form, service_type: e.target.value })}>
|
||||
{Object.entries(SERVICE_TYPE_MAP).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">状态</label>
|
||||
<select className="admin-form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
|
||||
<option value="active">上架</option>
|
||||
<option value="inactive">下架</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">服务描述</label>
|
||||
<textarea
|
||||
className="admin-form-input admin-form-textarea"
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="输入服务描述"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">服务特性(逗号分隔)</label>
|
||||
<input className="admin-form-input" value={form.features} onChange={e => setForm({ ...form, features: e.target.value })} placeholder="特性1, 特性2, 特性3" />
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">价格(元, 0表示面议)</label>
|
||||
<input className="admin-form-input" type="number" value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
|
||||
<button className="admin-btn admin-btn-outline" onClick={() => { setShowModal(false); setEditingService(null); }}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Assignment Modal */}
|
||||
{showAgentModal && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadeIn var(--transition-normal)',
|
||||
}} onClick={() => setShowAgentModal(false)}>
|
||||
<div style={{
|
||||
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 480, maxHeight: '70vh', overflow: 'auto',
|
||||
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-lg)' }}>
|
||||
选择要绑定的Agent
|
||||
</h3>
|
||||
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-xl)' }}>
|
||||
从已发布的市场应用中选择一个Agent绑定到该应用定制服务
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)', maxHeight: 300, overflowY: 'auto' }}>
|
||||
{agents.map(agent => (
|
||||
<div
|
||||
key={agent.id}
|
||||
style={{
|
||||
padding: 'var(--space-lg)', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-lg)',
|
||||
cursor: 'pointer', transition: 'all var(--transition-fast)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}
|
||||
onClick={() => handleAssignAgent(agent.id)}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--color-accent)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 'var(--text-sm)', color: 'var(--color-text-primary)' }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{agent.description}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-disabled)', marginTop: 4 }}>发布者: {agent.publisher_name}</div>
|
||||
</div>
|
||||
<button className="admin-btn admin-btn-primary admin-btn-sm">选择</button>
|
||||
</div>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-xl)' }}>暂无可用Agent</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Services;
|
||||
@@ -0,0 +1,772 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
type TabKey = 'basic' | 'logo' | 'email' | 'sms' | 'registration';
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'basic', label: '基础设置', icon: <><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></> },
|
||||
{ key: 'logo', label: 'Logo 设置', icon: <><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></> },
|
||||
{ key: 'email', label: '邮件配置', icon: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></> },
|
||||
{ key: 'sms', label: '短信配置', icon: <><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></> },
|
||||
{ key: 'registration', label: '注册设置', icon: <><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></> },
|
||||
];
|
||||
|
||||
const SiteSettings: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('basic');
|
||||
const [brand, setBrand] = useState<Record<string, string>>({
|
||||
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
|
||||
});
|
||||
const [mail, setMail] = useState({
|
||||
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
|
||||
});
|
||||
const [sms, setSms] = useState({
|
||||
provider: '', api_key: '', api_secret: '', sign_name: '', template_code: '',
|
||||
});
|
||||
const [registration, setRegistration] = useState({
|
||||
allow_register: true,
|
||||
require_email_verify: false,
|
||||
require_phone_verify: false,
|
||||
default_role: 'user',
|
||||
welcome_message: '',
|
||||
});
|
||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [showEmailTest, setShowEmailTest] = useState(false);
|
||||
const [emailTestTo, setEmailTestTo] = useState('');
|
||||
const [emailTestSubject, setEmailTestSubject] = useState('');
|
||||
const [emailTestBody, setEmailTestBody] = useState('');
|
||||
const [emailGenerating, setEmailGenerating] = useState(false);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [logoPreview, setLogoPreview] = useState('');
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// AI生成测试邮件内容
|
||||
const handleAIGenerateContent = async () => {
|
||||
setEmailGenerating(true);
|
||||
try {
|
||||
const res = await adminAPI.generateEmailContent('测试邮件');
|
||||
const data = (res as any).data || res;
|
||||
setEmailTestSubject(data.subject || '');
|
||||
setEmailTestBody(data.body || '');
|
||||
} catch (e) {
|
||||
const subjects = [
|
||||
'【测试】AIFlow邮件服务配置验证',
|
||||
'AIFlow平台 - 邮件投递测试',
|
||||
'测试邮件:您的SMTP服务已就绪',
|
||||
];
|
||||
const bodies = [
|
||||
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
|
||||
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
|
||||
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
|
||||
];
|
||||
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
|
||||
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
|
||||
} finally {
|
||||
setEmailGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load all configs
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const [siteRes, emailRes, smsRes, regRes] = await Promise.all([
|
||||
adminAPI.getSiteSettings(),
|
||||
adminAPI.getEmailConfig(),
|
||||
adminAPI.getSmsConfig(),
|
||||
adminAPI.getSiteSettings().then((r: any) => ({ data: r?.data?.registration || {} })).catch(() => ({ data: {} })),
|
||||
]);
|
||||
if (siteRes?.data) {
|
||||
setBrand({
|
||||
logo: siteRes.data.logo || siteRes.data.site_logo || '',
|
||||
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
|
||||
announcement: siteRes.data.announcement || '',
|
||||
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
|
||||
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
|
||||
});
|
||||
setLogoPreview(siteRes.data.logo || siteRes.data.site_logo || '');
|
||||
}
|
||||
if (emailRes?.data) {
|
||||
setMail({
|
||||
smtp_host: emailRes.data.smtp_host || '',
|
||||
smtp_port: emailRes.data.smtp_port || 587,
|
||||
username: emailRes.data.username || '',
|
||||
password: emailRes.data.password || '',
|
||||
from_address: emailRes.data.from_address || '',
|
||||
use_tls: emailRes.data.use_tls !== false,
|
||||
});
|
||||
}
|
||||
if (smsRes?.data) {
|
||||
setSms({
|
||||
provider: smsRes.data.provider || '',
|
||||
api_key: smsRes.data.api_key || '',
|
||||
api_secret: smsRes.data.api_secret || '',
|
||||
sign_name: smsRes.data.sign_name || '',
|
||||
template_code: smsRes.data.template_code || '',
|
||||
});
|
||||
}
|
||||
// Try to load registration settings from site settings
|
||||
try {
|
||||
const regData = (siteRes as any)?.data;
|
||||
if (regData?.allow_register !== undefined) {
|
||||
setRegistration({
|
||||
allow_register: regData.allow_register === true || regData.allow_register === 'true',
|
||||
require_email_verify: regData.require_email_verify === true || regData.require_email_verify === 'true',
|
||||
require_phone_verify: regData.require_phone_verify === true || regData.require_phone_verify === 'true',
|
||||
default_role: regData.default_role || 'user',
|
||||
welcome_message: regData.welcome_message || '',
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const showSaved = (block: string) => {
|
||||
setSavedMsg(block + ' 保存成功');
|
||||
setTimeout(() => setSavedMsg(null), 2000);
|
||||
};
|
||||
|
||||
const showTestResult = (result: string) => {
|
||||
setTestResult(result);
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
};
|
||||
|
||||
// Logo upload handler
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Preview locally
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setLogoPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload to server
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await adminAPI.uploadLogo(formData);
|
||||
const data = (res as any)?.data || res;
|
||||
const url = data?.url || data?.logo_url || '';
|
||||
if (url) {
|
||||
setBrand({ ...brand, logo: url });
|
||||
showSaved('Logo');
|
||||
}
|
||||
} catch (e: any) {
|
||||
showTestResult('Logo 上传失败: ' + (e.detail || e.message || '未知错误'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSiteSettings = async () => {
|
||||
try {
|
||||
await adminAPI.updateSiteSettings({
|
||||
logo: brand.logo || logoPreview,
|
||||
site_name: brand.siteName,
|
||||
announcement: brand.announcement,
|
||||
icp_number: brand.icpNumber,
|
||||
contact_phone: brand.contactPhone,
|
||||
});
|
||||
showSaved('基础设置');
|
||||
} catch (e: any) {
|
||||
showTestResult('保存失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const saveEmailConfig = async () => {
|
||||
try {
|
||||
await adminAPI.updateEmailConfig({
|
||||
smtp_host: mail.smtp_host,
|
||||
smtp_port: mail.smtp_port,
|
||||
username: mail.username,
|
||||
password: mail.password,
|
||||
from_address: mail.from_address,
|
||||
use_tls: mail.use_tls,
|
||||
});
|
||||
showSaved('邮件配置');
|
||||
} catch (e: any) {
|
||||
showTestResult('保存失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const saveSmsConfig = async () => {
|
||||
try {
|
||||
await adminAPI.updateSmsConfig({
|
||||
provider: sms.provider,
|
||||
api_key: sms.api_key,
|
||||
api_secret: sms.api_secret,
|
||||
sign_name: sms.sign_name,
|
||||
template_code: sms.template_code,
|
||||
});
|
||||
showSaved('短信配置');
|
||||
} catch (e: any) {
|
||||
showTestResult('保存失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const saveRegistrationSettings = async () => {
|
||||
try {
|
||||
await adminAPI.updateSiteSettings({
|
||||
allow_register: String(registration.allow_register),
|
||||
require_email_verify: String(registration.require_email_verify),
|
||||
require_phone_verify: String(registration.require_phone_verify),
|
||||
default_role: registration.default_role,
|
||||
welcome_message: registration.welcome_message,
|
||||
});
|
||||
showSaved('注册设置');
|
||||
} catch (e: any) {
|
||||
showTestResult('保存失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
if (!emailTestTo.trim()) {
|
||||
showTestResult('请输入收件邮箱地址');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminAPI.updateEmailConfig({
|
||||
smtp_host: mail.smtp_host,
|
||||
smtp_port: mail.smtp_port,
|
||||
username: mail.username,
|
||||
password: mail.password,
|
||||
from_address: mail.from_address,
|
||||
use_tls: mail.use_tls,
|
||||
});
|
||||
const res = await adminAPI.testEmail({
|
||||
test_to: emailTestTo.trim(),
|
||||
test_subject: emailTestSubject || undefined,
|
||||
test_body: emailTestBody || undefined,
|
||||
});
|
||||
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
|
||||
setShowEmailTest(false);
|
||||
} catch (e: any) {
|
||||
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const openEmailTest = () => {
|
||||
setEmailTestTo('');
|
||||
setEmailTestSubject('');
|
||||
setEmailTestBody('');
|
||||
setShowEmailTest(true);
|
||||
};
|
||||
|
||||
const sendTestSms = async () => {
|
||||
try {
|
||||
const res = await adminAPI.testSms();
|
||||
showTestResult('测试短信发送成功: ' + (res?.message || 'OK'));
|
||||
} catch (e: any) {
|
||||
showTestResult('测试短信发送失败: ' + (e.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>加载中...</div>;
|
||||
}
|
||||
|
||||
const FormGroup = ({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) => (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
{label}
|
||||
{required && <span style={{ color: 'var(--color-error)' }}> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const FormRow = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="admin-form-row">{children}</div>
|
||||
);
|
||||
|
||||
const FormActions = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="admin-form-actions">{children}</div>
|
||||
);
|
||||
|
||||
// ========== Render Tab Content ==========
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
// ── 基础设置 ──
|
||||
case 'basic':
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">基础信息</h3>
|
||||
</div>
|
||||
<FormRow>
|
||||
<FormGroup label="网站名称" required>
|
||||
<input className="admin-form-input" value={brand.siteName}
|
||||
onChange={e => setBrand({ ...brand, siteName: e.target.value })}
|
||||
placeholder="例如: 七虎AI" />
|
||||
</FormGroup>
|
||||
<FormGroup label="LOGO URL">
|
||||
<input className="admin-form-input" value={brand.logo}
|
||||
onChange={e => { setBrand({ ...brand, logo: e.target.value }); setLogoPreview(e.target.value); }}
|
||||
placeholder="https://... 或上传后自动填入" />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormGroup label="首页公告">
|
||||
<textarea className="admin-form-input admin-form-textarea"
|
||||
value={brand.announcement}
|
||||
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
|
||||
style={{ minHeight: 80 }}
|
||||
placeholder="将在首页顶部显示的公告内容..." />
|
||||
</FormGroup>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="备案号">
|
||||
<input className="admin-form-input" value={brand.icpNumber}
|
||||
onChange={e => setBrand({ ...brand, icpNumber: e.target.value })}
|
||||
placeholder="例如: 京ICP备XXXXXXXX号" />
|
||||
</FormGroup>
|
||||
<FormGroup label="客服联系方式">
|
||||
<input className="admin-form-input" value={brand.contactPhone}
|
||||
onChange={e => setBrand({ ...brand, contactPhone: e.target.value })}
|
||||
placeholder="电话 / 邮箱 / 微信" />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormActions>
|
||||
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
|
||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||
保存设置
|
||||
</button>
|
||||
</FormActions>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Logo 设置 ──
|
||||
case 'logo':
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">Logo 上传</h3>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||
建议尺寸 200x60px,支持 PNG/JPG/SVG/WEBP,最大 2MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 'var(--space-2xl)', alignItems: 'flex-start',
|
||||
flexWrap: 'wrap' as const,
|
||||
}}>
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
width: 280, minHeight: 160,
|
||||
border: '2px dashed var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
}}>
|
||||
{logoPreview ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo Preview"
|
||||
style={{ maxWidth: 240, maxHeight: 80, objectFit: 'contain' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
|
||||
实时预览
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<SVGIcon size={32}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</SVGIcon>
|
||||
<div style={{ marginTop: 8, fontSize: 'var(--text-sm)' }}>暂无 Logo</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)' }}>点击上传或拖拽到此处</div>
|
||||
</div>
|
||||
)}
|
||||
{logoUploading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)', borderRadius: 'var(--radius-lg)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Actions */}
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)', marginBottom: 'var(--space-lg)' }}>
|
||||
上传平台 Logo,将显示在网站标题栏、首页导航等位置。
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
onChange={handleLogoUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
disabled={logoUploading}
|
||||
>
|
||||
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
|
||||
{logoUploading ? '上传中...' : '选择文件上传'}
|
||||
</button>
|
||||
{logoPreview && (
|
||||
<button
|
||||
className="admin-btn admin-btn-outline"
|
||||
onClick={() => {
|
||||
setLogoPreview('');
|
||||
setBrand({ ...brand, logo: '' });
|
||||
if (logoInputRef.current) logoInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<SVGIcon><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
|
||||
移除 Logo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL Fallback */}
|
||||
<div style={{ marginTop: 'var(--space-xl)', paddingTop: 'var(--space-lg)',
|
||||
borderTop: '1px solid var(--color-border)' }}>
|
||||
<FormGroup label="或使用外链 URL">
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
<input className="admin-form-input"
|
||||
value={brand.logo}
|
||||
onChange={e => { setBrand({ ...brand, logo: e.target.value }); setLogoPreview(e.target.value || ''); }}
|
||||
placeholder="https://example.com/logo.png"
|
||||
style={{ flex: 1 }} />
|
||||
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 邮件配置 ──
|
||||
case 'email':
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">SMTP 邮件服务</h3>
|
||||
</div>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="SMTP 服务器" required>
|
||||
<input className="admin-form-input" value={mail.smtp_host}
|
||||
onChange={e => setMail({ ...mail, smtp_host: e.target.value })}
|
||||
placeholder="例如: smtp.gmail.com" />
|
||||
</FormGroup>
|
||||
<FormGroup label="端口">
|
||||
<input className="admin-form-input" type="number" value={mail.smtp_port}
|
||||
onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="用户名" required>
|
||||
<input className="admin-form-input" value={mail.username}
|
||||
onChange={e => setMail({ ...mail, username: e.target.value })} />
|
||||
</FormGroup>
|
||||
<FormGroup label="密码" required>
|
||||
<input className="admin-form-input" type="password" value={mail.password}
|
||||
onChange={e => setMail({ ...mail, password: e.target.value })} />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="发件地址" required>
|
||||
<input className="admin-form-input" value={mail.from_address}
|
||||
onChange={e => setMail({ ...mail, from_address: e.target.value })}
|
||||
placeholder="noreply@example.com" />
|
||||
</FormGroup>
|
||||
<FormGroup label="安全协议">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
||||
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<input type="checkbox" checked={mail.use_tls}
|
||||
onChange={e => setMail({ ...mail, use_tls: e.target.checked })}
|
||||
style={{ accentColor: 'var(--color-accent)' }} />
|
||||
启用 TLS 加密
|
||||
</label>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormActions>
|
||||
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
|
||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||
保存配置
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
|
||||
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
||||
发送测试邮件
|
||||
</button>
|
||||
</FormActions>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 短信配置 ──
|
||||
case 'sms':
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">短信服务</h3>
|
||||
</div>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="短信服务商">
|
||||
<select className="admin-form-input" value={sms.provider}
|
||||
onChange={e => setSms({ ...sms, provider: e.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
<option value="阿里云短信">阿里云短信</option>
|
||||
<option value="腾讯云短信">腾讯云短信</option>
|
||||
<option value="华为云短信">华为云短信</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
<FormGroup label="短信签名">
|
||||
<input className="admin-form-input" value={sms.sign_name}
|
||||
onChange={e => setSms({ ...sms, sign_name: e.target.value })} />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="API Key (AccessKey ID)">
|
||||
<input className="admin-form-input" value={sms.api_key}
|
||||
onChange={e => setSms({ ...sms, api_key: e.target.value })} />
|
||||
</FormGroup>
|
||||
<FormGroup label="API Secret (AccessKey Secret)">
|
||||
<input className="admin-form-input" type="password" value={sms.api_secret}
|
||||
onChange={e => setSms({ ...sms, api_secret: e.target.value })} />
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormGroup label="模板编码" required>
|
||||
<input className="admin-form-input" value={sms.template_code}
|
||||
onChange={e => setSms({ ...sms, template_code: e.target.value })}
|
||||
placeholder="例如: SMS_00000001"
|
||||
style={{ maxWidth: 400 }} />
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<button className="admin-btn admin-btn-primary" onClick={saveSmsConfig}>
|
||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||
保存配置
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-outline" onClick={sendTestSms}>
|
||||
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
||||
发送测试短信
|
||||
</button>
|
||||
</FormActions>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 注册设置 ──
|
||||
case 'registration':
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">注册与准入</h3>
|
||||
</div>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="允许新用户注册">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
||||
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<input type="checkbox" checked={registration.allow_register}
|
||||
onChange={e => setRegistration({ ...registration, allow_register: e.target.checked })}
|
||||
style={{ accentColor: 'var(--color-accent)' }} />
|
||||
开启注册(关闭后仅管理员可手动创建用户)
|
||||
</label>
|
||||
</FormGroup>
|
||||
<FormGroup label="默认角色">
|
||||
<select className="admin-form-input" value={registration.default_role}
|
||||
onChange={e => setRegistration({ ...registration, default_role: e.target.value })}>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="member">会员</option>
|
||||
<option value="developer">开发者</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup label="邮箱验证">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
||||
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<input type="checkbox" checked={registration.require_email_verify}
|
||||
onChange={e => setRegistration({ ...registration, require_email_verify: e.target.checked })}
|
||||
style={{ accentColor: 'var(--color-accent)' }} />
|
||||
注册时要求邮箱验证
|
||||
</label>
|
||||
</FormGroup>
|
||||
<FormGroup label="手机验证">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
||||
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<input type="checkbox" checked={registration.require_phone_verify}
|
||||
onChange={e => setRegistration({ ...registration, require_phone_verify: e.target.checked })}
|
||||
style={{ accentColor: 'var(--color-accent)' }} />
|
||||
注册时要求短信验证
|
||||
</label>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<FormGroup label="欢迎语(注册成功后展示)">
|
||||
<textarea className="admin-form-input admin-form-textarea"
|
||||
value={registration.welcome_message}
|
||||
onChange={e => setRegistration({ ...registration, welcome_message: e.target.value })}
|
||||
style={{ minHeight: 80 }}
|
||||
placeholder="欢迎加入七虎AI!从这里开始你的 AI 创造之旅..." />
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<button className="admin-btn admin-btn-primary" onClick={saveRegistrationSettings}>
|
||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||
保存设置
|
||||
</button>
|
||||
</FormActions>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Main Render ==========
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>站点设置</h1>
|
||||
{savedMsg && (
|
||||
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
|
||||
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
||||
{' '}{savedMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tab Bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
marginBottom: 'var(--space-2xl)',
|
||||
overflowX: 'auto' as const,
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 18px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
transition: 'all var(--transition-fast)',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
<SVGIcon size={15}>{tab.icon}</SVGIcon>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{renderTabContent()}
|
||||
|
||||
{/* Email Test Modal */}
|
||||
{showEmailTest && (
|
||||
<div className="admin-modal-overlay" onClick={() => setShowEmailTest(false)}>
|
||||
<div className="admin-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="admin-modal-header">
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: 0 }}>
|
||||
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
|
||||
发送测试邮件
|
||||
</h3>
|
||||
<button className="admin-modal-close" onClick={() => setShowEmailTest(false)}>
|
||||
<SVGIcon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<FormGroup label="收件邮箱" required>
|
||||
<input className="admin-form-input" type="email" value={emailTestTo}
|
||||
onChange={e => setEmailTestTo(e.target.value)}
|
||||
placeholder="例如: admin@example.com" />
|
||||
</FormGroup>
|
||||
<FormGroup label="邮件主题">
|
||||
<input className="admin-form-input" value={emailTestSubject}
|
||||
onChange={e => setEmailTestSubject(e.target.value)} />
|
||||
</FormGroup>
|
||||
<FormGroup label="邮件内容">
|
||||
<textarea className="admin-form-input admin-form-textarea"
|
||||
value={emailTestBody}
|
||||
onChange={e => setEmailTestBody(e.target.value)}
|
||||
style={{ minHeight: 120 }} />
|
||||
</FormGroup>
|
||||
<div style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
<button className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
onClick={handleAIGenerateContent} disabled={emailGenerating}>
|
||||
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button className="admin-btn admin-btn-primary" onClick={sendTestEmail} disabled={!emailTestTo.trim()}>
|
||||
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
||||
发送测试邮件
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
|
||||
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
|
||||
color: '#fff', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 9999, fontSize: 'var(--text-sm)',
|
||||
maxWidth: 420,
|
||||
}}>
|
||||
{testResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteSettings;
|
||||
@@ -0,0 +1,666 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { SkillState } from '@/types';
|
||||
|
||||
type AdminSkill = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
owner_username?: string;
|
||||
skill_name: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
category?: string;
|
||||
category_label?: string;
|
||||
state: SkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
skill_md_path?: string;
|
||||
last_used_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
skill_md_content?: string;
|
||||
publish_records?: PublishRecord[];
|
||||
};
|
||||
|
||||
interface PublishRecord {
|
||||
id: string;
|
||||
price: number;
|
||||
status: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
interface SkillVersion {
|
||||
id: string;
|
||||
version: number;
|
||||
description?: string;
|
||||
skill_md_content?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface SkillsStats {
|
||||
total_skills: number;
|
||||
by_state: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
total_published: number;
|
||||
total_versions: number;
|
||||
}
|
||||
|
||||
const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: string }> = {
|
||||
baby: { label: '萌芽期', color: '#6B7280', bg: '#F3F4F6' },
|
||||
main: { label: '主力期', color: '#10B981', bg: '#D1FAE5' },
|
||||
staging: { label: '灰度期', color: '#F59E0B', bg: '#FEF3C7' },
|
||||
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'writing', label: '文字创作' },
|
||||
{ id: 'coding', label: '代码能力' },
|
||||
{ id: 'data_analysis', label: '数据分析' },
|
||||
{ id: 'qa', label: '知识问答' },
|
||||
{ id: 'automation', label: '流程自动化' },
|
||||
{ id: 'creativity', label: '创意生成' },
|
||||
{ id: 'communication', label: '沟通协作' },
|
||||
{ id: 'reasoning', label: '逻辑推理' },
|
||||
{ id: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const Skills: React.FC = () => {
|
||||
const [skills, setSkills] = useState<AdminSkill[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterState, setFilterState] = useState<string>('');
|
||||
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||
|
||||
// Create/Edit Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSkill, setEditingSkill] = useState<AdminSkill | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
skill_name: '',
|
||||
user_id: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||||
});
|
||||
|
||||
// Version Drawer
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [versions, setVersions] = useState<any[]>([]);
|
||||
const [versionSkill, setVersionSkill] = useState<AdminSkill | null>(null);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
|
||||
// Users list (for owner selection)
|
||||
const [users, setUsers] = useState<{id: string; username: string}[]>([]);
|
||||
|
||||
// Confirm delete
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminSkill | null>(null);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{msg: string; type: 'success' | 'error'} | null>(null);
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load skills list
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterState) params.state = filterState;
|
||||
if (filterCategory) params.category = filterCategory;
|
||||
|
||||
const result = await adminAPI.getAdminSkills(params) as any;
|
||||
const data = result.data || result;
|
||||
setSkills(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminSkillsStats() as any;
|
||||
const data = result.data || result;
|
||||
setStats(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Load users for owner selection
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getUsers({ page_size: 100 }) as any;
|
||||
const data = result.data || result;
|
||||
setUsers((data.items || data || []).slice(0, 20));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [page, keyword, filterState, filterCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
// Search
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadSkills();
|
||||
};
|
||||
|
||||
// Open create modal
|
||||
const openCreate = () => {
|
||||
setEditingSkill(null);
|
||||
setForm({
|
||||
skill_name: '',
|
||||
user_id: users[0]?.id || '',
|
||||
description: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEdit = (skill: AdminSkill) => {
|
||||
setEditingSkill(skill);
|
||||
setForm({
|
||||
skill_name: skill.skill_name,
|
||||
user_id: skill.user_id,
|
||||
description: skill.description || '',
|
||||
tags: (skill.tags || []).join(', '),
|
||||
category: skill.category || '',
|
||||
skill_md_content: skill.skill_md_content || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Save skill
|
||||
const handleSave = async () => {
|
||||
if (!form.skill_name.trim()) {
|
||||
showToast('技能名称不能为空', 'error');
|
||||
return;
|
||||
}
|
||||
if (!form.user_id) {
|
||||
showToast('请选择所属用户', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const payload: any = {
|
||||
skill_name: form.skill_name,
|
||||
user_id: form.user_id,
|
||||
description: form.description,
|
||||
tags,
|
||||
category: form.category || undefined,
|
||||
skill_md_content: form.skill_md_content,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingSkill) {
|
||||
await adminAPI.updateAdminSkill(editingSkill.id, payload);
|
||||
showToast('技能已更新');
|
||||
} else {
|
||||
await adminAPI.createAdminSkill(payload);
|
||||
showToast('技能已创建');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete skill
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminSkill(deleteTarget.id);
|
||||
showToast('技能已删除');
|
||||
setDeleteTarget(null);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Change state
|
||||
const handleChangeState = async (skill: AdminSkill, newState: string) => {
|
||||
try {
|
||||
await adminAPI.changeAdminSkillState(skill.id, { state: newState });
|
||||
showToast(`状态已变更为 ${STATE_CONFIG[newState as SkillState].label}`);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`状态修改失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Load version history
|
||||
const handleShowVersions = async (skill: AdminSkill) => {
|
||||
setVersionSkill(skill);
|
||||
setShowVersions(true);
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const result = await adminAPI.getAdminSkillVersions(skill.id) as any;
|
||||
const data = result.data || result;
|
||||
setVersions(data.versions || []);
|
||||
} catch (e: any) {
|
||||
showToast(`加载版本历史失败: ${e.message || e}`, 'error');
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Rollback version
|
||||
const handleRollback = async (skill: AdminSkill, targetVersion: number) => {
|
||||
if (!confirm(`确定回滚到版本 ${targetVersion} 吗?`)) return;
|
||||
try {
|
||||
await adminAPI.rollbackAdminSkill(skill.id, { version: targetVersion });
|
||||
showToast(`已回滚到版本 ${targetVersion}`);
|
||||
setShowVersions(false);
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`回滚失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Publish/Unpublish
|
||||
const handlePublish = async (skill: AdminSkill) => {
|
||||
const price = parseFloat(prompt('设置定价(元,0=免费):', '0') || '0');
|
||||
if (isNaN(price) || price < 0) return;
|
||||
try {
|
||||
await adminAPI.publishAdminSkill(skill.id, { price, market_description: skill.description || '' });
|
||||
showToast('已发布到应用市场');
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`发布失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async (skill: AdminSkill) => {
|
||||
if (!confirm('确定从市场下架吗?')) return;
|
||||
try {
|
||||
await adminAPI.unpublishAdminSkill(skill.id);
|
||||
showToast('已从市场下架');
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`下架失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const isPublished = (skill: AdminSkill) =>
|
||||
(skill.publish_records || []).some(r => r.status === 'published');
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`toast ${toast.type}`} style={{
|
||||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||||
padding: '12px 20px', borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>技能管理</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||||
管理全平台 AI 技能,支持版本历史与市场发布
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn primary" onClick={openCreate}>+ 创建技能</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_skills}</div>
|
||||
<div className="stat-label">技能总数</div>
|
||||
</div>
|
||||
{Object.entries(STATE_CONFIG).map(([state, cfg]) => (
|
||||
<div key={state} className="stat-card" style={{ borderLeft: `3px solid ${cfg.color}` }}>
|
||||
<div className="stat-value">{stats.by_state[state] || 0}</div>
|
||||
<div className="stat-label">{cfg.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||||
<div className="stat-value">{stats.total_published}</div>
|
||||
<div className="stat-label">已发布市场</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||||
<div className="stat-value">{stats.total_versions}</div>
|
||||
<div className="stat-label">版本记录</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
placeholder="搜索技能名称或描述..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<select className="admin-form-input" value={filterState} onChange={e => { setFilterState(e.target.value); setPage(1); }} style={{ maxWidth: 140 }}>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="admin-form-input" value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} style={{ maxWidth: 160 }}>
|
||||
<option value="">全部分类</option>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||||
{(keyword || filterState || filterCategory) && (
|
||||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterState(''); setFilterCategory(''); setPage(1); }}>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>技能名称</th>
|
||||
<th>状态</th>
|
||||
<th>分类</th>
|
||||
<th>所有者</th>
|
||||
<th>版本</th>
|
||||
<th>使用次数</th>
|
||||
<th>市场</th>
|
||||
<th>更新时间</th>
|
||||
<th style={{ width: 280 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||||
) : skills.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
|
||||
) : skills.map(skill => {
|
||||
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
|
||||
const published = isPublished(skill);
|
||||
return (
|
||||
<tr key={skill.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{skill.skill_name}</div>
|
||||
{skill.description && (
|
||||
<div style={{ fontSize: 12, color: '#6B7280', marginTop: 2, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{skill.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
|
||||
))}
|
||||
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12,
|
||||
background: cfg.bg, color: cfg.color, fontWeight: 500
|
||||
}}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>{skill.category_label || '-'}</td>
|
||||
<td>{skill.owner_username || skill.user_id}</td>
|
||||
<td>v{skill.version}</td>
|
||||
<td>{skill.use_count}</td>
|
||||
<td>
|
||||
{published ? (
|
||||
<span style={{ color: '#10B981', fontSize: 13 }}>✓ 已发布</span>
|
||||
) : (
|
||||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>未发布</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||||
{skill.updated_at ? skill.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button className="admin-btn small" onClick={() => openEdit(skill)}>编辑</button>
|
||||
<button className="admin-btn small" onClick={() => handleShowVersions(skill)}>版本</button>
|
||||
{published ? (
|
||||
<button className="admin-btn small warning" onClick={() => handleUnpublish(skill)}>下架</button>
|
||||
) : (
|
||||
<button className="admin-btn small success" onClick={() => handlePublish(skill)}>发布</button>
|
||||
)}
|
||||
{/* State change */}
|
||||
<select
|
||||
className="admin-form-input small"
|
||||
value={skill.state}
|
||||
onChange={e => handleChangeState(skill, e.target.value)}
|
||||
style={{ padding: '2px 4px', fontSize: 12, maxWidth: 90 }}
|
||||
>
|
||||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="admin-btn small danger" onClick={() => setDeleteTarget(skill)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span style={{ color: '#6B7280', fontSize: 13 }}>
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||||
return p <= totalPages && (
|
||||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`}
|
||||
onClick={() => setPage(p)}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{editingSkill ? '编辑技能' : '创建技能'}</h3>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能名称 *</label>
|
||||
<input className="admin-form-input" value={form.skill_name}
|
||||
onChange={e => setForm(f => ({ ...f, skill_name: e.target.value }))}
|
||||
placeholder="例如: 代码审查助手" />
|
||||
</div>
|
||||
{!editingSkill && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属用户 *</label>
|
||||
<select className="admin-form-input" value={form.user_id}
|
||||
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">分类</label>
|
||||
<select className="admin-form-input" value={form.category}
|
||||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
|
||||
<option value="">选择分类</option>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">标签(逗号分隔)</label>
|
||||
<input className="admin-form-input" value={form.tags}
|
||||
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
placeholder="例如: 代码, 审查, 自动化" />
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">描述</label>
|
||||
<textarea className="admin-form-input" value={form.description} rows={3}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="描述技能的功能和使用场景..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">SKILL.md 内容(技能定义)</label>
|
||||
<textarea
|
||||
className="admin-form-input"
|
||||
value={form.skill_md_content}
|
||||
onChange={e => setForm(f => ({ ...f, skill_md_content: e.target.value }))}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 400, lineHeight: 1.6 }}
|
||||
placeholder="## 技能定义..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button className="admin-btn primary" onClick={handleSave}>
|
||||
{editingSkill ? '保存更改' : '创建技能'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History Drawer */}
|
||||
{showVersions && versionSkill && (
|
||||
<div className="modal-overlay" onClick={() => setShowVersions(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>版本历史 - {versionSkill.skill_name}</h3>
|
||||
<button className="modal-close" onClick={() => setShowVersions(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{loadingVersions ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无版本记录</div>
|
||||
) : (
|
||||
<div>
|
||||
{versions.map((v, idx) => (
|
||||
<div key={v.id} style={{
|
||||
padding: 16, marginBottom: 12, border: `1px solid ${idx === 0 ? '#10B981' : '#E5E7EB'}`,
|
||||
borderRadius: 8, background: idx === 0 ? '#F0FDF4' : '#fff'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>版本 {v.version}</span>
|
||||
{idx === 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#10B981', background: '#D1FAE5', padding: '1px 8px', borderRadius: 12 }}>当前</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>{v.created_at?.slice(0, 16).replace('T', ' ')}</span>
|
||||
{idx > 0 && (
|
||||
<button className="admin-btn small" onClick={() => handleRollback(versionSkill, v.version)}>回滚至此</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{v.description && <div style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>{v.description}</div>}
|
||||
{v.skill_md_content && (
|
||||
<details>
|
||||
<summary style={{ fontSize: 13, color: '#6366F1', cursor: 'pointer' }}>查看 SKILL.md 内容</summary>
|
||||
<pre style={{ fontSize: 12, background: '#F9FAFB', padding: 12, borderRadius: 6, marginTop: 8, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{v.skill_md_content}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm */}
|
||||
{deleteTarget && (
|
||||
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
|
||||
<div className="modal-content" style={{ maxWidth: 400 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>确认删除</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>确定要删除技能 <strong>{deleteTarget.skill_name}</strong> 吗?</p>
|
||||
<p style={{ fontSize: 13, color: '#EF4444' }}>此操作不可恢复,相关版本历史也将被删除。</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setDeleteTarget(null)}>取消</button>
|
||||
<button className="admin-btn danger" onClick={handleDelete}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||||
.modal-body { padding: 24px; overflow-y: auto; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skills;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Tenant {
|
||||
id: string; name: string; slug: string; logo: string; description: string;
|
||||
status: string; max_members: number; max_storage_mb: number;
|
||||
max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string;
|
||||
}
|
||||
interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; }
|
||||
interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; }
|
||||
|
||||
const Tenant: React.FC = () => {
|
||||
const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list');
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [selectedTenant, setSelectedTenant] = useState<string>('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||
|
||||
const fetchTenants = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getTenants(page);
|
||||
const d = (res as any)?.data || res;
|
||||
setTenants(d?.items || []);
|
||||
setTotal(d?.total || 0);
|
||||
} catch (e) { console.error('fetchTenants:', e); }
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getTenantStats();
|
||||
setStats((res as any)?.data || res);
|
||||
} catch (e) { console.error('fetchStats:', e); }
|
||||
}, []);
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
if (!selectedTenant) return;
|
||||
try {
|
||||
const res = await adminAPI.getTenantMembers(selectedTenant);
|
||||
const d = (res as any)?.data || res;
|
||||
setMembers(d?.items || []);
|
||||
} catch (e) { console.error('fetchMembers:', e); }
|
||||
}, [selectedTenant]);
|
||||
|
||||
useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]);
|
||||
useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`;
|
||||
await adminAPI.createTenant(params);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||
fetchTenants();
|
||||
} catch (e) { console.error('create:', e); }
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
|
||||
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#6B7280', viewer: '#9CA3AF' };
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="responsive-grid-auto" style={{ gap: '16px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ label: '总租户数', value: stats.total_tenants, color: '#6C63FF' },
|
||||
{ label: '活跃租户', value: stats.active_tenants, color: '#10b981' },
|
||||
{ label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' },
|
||||
{ label: '总成员数', value: stats.total_members, color: '#3b82f6' },
|
||||
{ label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setTab(k)}
|
||||
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280',
|
||||
fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||
))}
|
||||
{tab === 'list' && (
|
||||
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
|
||||
border: 'none', cursor: 'pointer', background: '#6C63FF', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
|
||||
+ 创建租户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'list' && (
|
||||
<div>
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F8F9FC' }}>
|
||||
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.slug}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 10)}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
|
||||
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)',
|
||||
background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '12px' }}>管理</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{total > 20 && (
|
||||
<div className="pagination-bar" style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
|
||||
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
|
||||
style={{ padding: '8px 12px', borderRadius: '8px', background: '#FFFFFF', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px' }}>
|
||||
<option value="">选择租户...</option>
|
||||
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{selectedTenant && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F8F9FC' }}>
|
||||
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(m => (
|
||||
<tr key={m.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{m.email || '-'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{m.joined_at?.substring(0, 16)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'stats' && stats && (
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '32px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<div className="responsive-grid-2" style={{ gap: '24px' }}>
|
||||
{Object.entries(stats).map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#F8F9FC', borderRadius: '8px' }}>
|
||||
<span style={{ fontSize: '14px', color: '#6B7280' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
|
||||
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6C63FF' }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreate && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
|
||||
onClick={() => setShowCreate(false)}>
|
||||
<div onClick={e => e.stopPropagation()} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw', boxShadow: '0 4px 24px rgba(0,0,0,0.12)' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px', color: '#1A1F36' }}>创建租户</h2>
|
||||
{[
|
||||
{ k: 'name', label: '租户名称', type: 'text' },
|
||||
{ k: 'slug', label: '标识(英文)', type: 'text' },
|
||||
{ k: 'description', label: '描述', type: 'text' },
|
||||
{ k: 'max_members', label: '最大成员数', type: 'number' },
|
||||
{ k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' },
|
||||
{ k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' },
|
||||
].map(f => (
|
||||
<div key={f.k} style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#6B7280', marginBottom: '6px' }}>{f.label}</label>
|
||||
<input type={f.type} value={(form as any)[f.k]} onChange={e => setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })}
|
||||
style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#F8F9FC', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid rgba(0,0,0,0.12)', background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '14px' }}>取消</button>
|
||||
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6C63FF', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tenant;
|
||||
@@ -0,0 +1,584 @@
|
||||
/* ===== Admin Users Page ===== */
|
||||
.admin-users-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xl);
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.admin-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-xl);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-card-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.admin-card-subtitle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Toolbar ===== */
|
||||
.users-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.users-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.users-search svg {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.users-search-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.users-search-input::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.users-filters {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.users-filter-select {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.users-filter-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
.users-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table thead {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-table tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.users-table tbody tr:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.users-cell-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.users-cell-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.users-cell-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs) !important;
|
||||
}
|
||||
|
||||
.users-cell-date {
|
||||
font-size: var(--text-xs) !important;
|
||||
color: var(--color-text-tertiary) !important;
|
||||
}
|
||||
|
||||
.users-cell-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.users-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.users-action-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.users-action-btn.danger:hover {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.users-action-btn.success:hover {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.users-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.users-level-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.users-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-3xl);
|
||||
color: var(--color-text-disabled);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ===== User Detail Modal ===== */
|
||||
.admin-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
animation: slideUp var(--transition-normal);
|
||||
}
|
||||
|
||||
.admin-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xl) var(--space-2xl);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-modal-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-modal-close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-modal-body {
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.user-detail-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.user-detail-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-detail-basic h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.user-detail-tags {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-detail-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-detail-tag.role-tag {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.user-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.user-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== Register Settings ===== */
|
||||
.register-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xl);
|
||||
}
|
||||
|
||||
.register-settings-section {
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.register-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-lg);
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.register-section-title svg {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.register-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.register-setting-row + .register-setting-row {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.register-setting-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.register-setting-info strong {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.register-setting-info span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--color-border);
|
||||
border-radius: 24px;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toggle-switch input:disabled + .toggle-slider {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Aliyun Config */
|
||||
.aliyun-config {
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.aliyun-config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.aliyun-config-header svg {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.aliyun-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.aliyun-config-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.aliyun-config-grid .form-group label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.users-form-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.users-form-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.users-form-input::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Save Button */
|
||||
.users-save-btn {
|
||||
align-self: flex-end;
|
||||
padding: var(--space-md) var(--space-2xl);
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.users-save-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.users-search {
|
||||
min-width: auto;
|
||||
}
|
||||
.users-filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.aliyun-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import { mockUsers, mockMembershipPlans } from '@/mock/data';
|
||||
import type { User } from '@/types';
|
||||
import './Users.css';
|
||||
|
||||
// ===== SVG Icons =====
|
||||
const IconSearch = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconEye = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconX = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconShield = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ===== Helpers =====
|
||||
const realNameStatusMap: Record<string, { label: string; color: string; bg: string }> = {
|
||||
verified: { label: '已实名', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
||||
pending: { label: '审核中', color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
|
||||
unverified: { label: '未实名', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string; bg: string }> = {
|
||||
active: { label: '正常', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
||||
banned: { label: '已封禁', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
|
||||
};
|
||||
|
||||
// ===== User Detail Modal =====
|
||||
interface UserDetailModalProps {
|
||||
user: User;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const UserDetailModal: React.FC<UserDetailModalProps> = ({ user, onClose }) => {
|
||||
const plan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
|
||||
const rn = realNameStatusMap[user.realNameStatus || 'unverified'];
|
||||
|
||||
return (
|
||||
<div className="admin-modal-overlay" onClick={onClose}>
|
||||
<div className="admin-modal users-detail-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="admin-modal-header">
|
||||
<h2>用户详情</h2>
|
||||
<button className="admin-modal-close" onClick={onClose}><IconClose /></button>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<div className="user-detail-top">
|
||||
<div className="user-detail-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-detail-basic">
|
||||
<h3>{user.username}</h3>
|
||||
<div className="user-detail-tags">
|
||||
<span className="user-detail-tag" style={{ color: rn.color, background: rn.bg }}>
|
||||
<IconShield />
|
||||
{rn.label}
|
||||
</span>
|
||||
<span className="user-detail-tag" style={{ color: statusMap[user.status].color, background: statusMap[user.status].bg }}>
|
||||
{statusMap[user.status].label}
|
||||
</span>
|
||||
<span className="user-detail-tag role-tag">
|
||||
{user.role === 'admin' ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-detail-grid">
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">用户ID</span>
|
||||
<span className="detail-value">{user.id}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">邮箱</span>
|
||||
<span className="detail-value">{user.email}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">手机号</span>
|
||||
<span className="detail-value">{user.phone || '未绑定'}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">实名姓名</span>
|
||||
<span className="detail-value">{user.realName || '--'}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">实名状态</span>
|
||||
<span className="detail-value" style={{ color: rn.color, fontWeight: 500 }}>{rn.label}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">会员等级</span>
|
||||
<span className="detail-value">{user.membership?.planName || '免费版'}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">会员到期</span>
|
||||
<span className="detail-value">{user.membership?.expireAt ? new Date(user.membership.expireAt).toLocaleDateString('zh-CN') : '--'}</span>
|
||||
</div>
|
||||
<div className="user-detail-item">
|
||||
<span className="detail-label">注册时间</span>
|
||||
<span className="detail-value">{new Date(user.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Main Component =====
|
||||
const UsersPage: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [levelFilter, setLevelFilter] = useState<string>('all');
|
||||
const [realNameFilter, setRealNameFilter] = useState<string>('all');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [localUsers, setLocalUsers] = useState<User[]>(mockUsers);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = localUsers.filter(u => u.role === 'user');
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.phone && u.phone.includes(q)) ||
|
||||
(u.realName && u.realName.includes(q))
|
||||
);
|
||||
}
|
||||
if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
|
||||
if (levelFilter !== 'all') result = result.filter(u => (u.membership?.level ?? 0).toString() === levelFilter);
|
||||
if (realNameFilter !== 'all') result = result.filter(u => u.realNameStatus === realNameFilter);
|
||||
return result;
|
||||
}, [localUsers, searchQuery, statusFilter, levelFilter, realNameFilter]);
|
||||
|
||||
const handleToggleUserStatus = (userId: string) => {
|
||||
setLocalUsers(prev =>
|
||||
prev.map(u => u.id === userId ? {
|
||||
...u,
|
||||
status: u.status === 'active' ? 'banned' as const : 'active' as const,
|
||||
} : u)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<h1 className="admin-page-title">用户管理</h1>
|
||||
|
||||
{/* ===== 用户列表 ===== */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">用户列表</h2>
|
||||
<span className="admin-card-count">共 {filteredUsers.length} 个用户</span>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="users-toolbar">
|
||||
<div className="users-search">
|
||||
<IconSearch />
|
||||
<input
|
||||
type="text"
|
||||
className="users-search-input"
|
||||
placeholder="搜索用户名、邮箱、手机号..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="users-filters">
|
||||
<select className="users-filter-select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="banned">已封禁</option>
|
||||
</select>
|
||||
<select className="users-filter-select" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
|
||||
<option value="all">全部等级</option>
|
||||
<option value="0">免费版</option>
|
||||
<option value="2">专业版</option>
|
||||
<option value="3">企业版</option>
|
||||
</select>
|
||||
<select className="users-filter-select" value={realNameFilter} onChange={e => setRealNameFilter(e.target.value)}>
|
||||
<option value="all">全部实名</option>
|
||||
<option value="verified">已实名</option>
|
||||
<option value="pending">审核中</option>
|
||||
<option value="unverified">未实名</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户表格 */}
|
||||
<div className="users-table-wrap">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>手机号</th>
|
||||
<th>实名状态</th>
|
||||
<th>会员等级</th>
|
||||
<th>注册时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => {
|
||||
const rn = realNameStatusMap[u.realNameStatus || 'unverified'];
|
||||
const st = statusMap[u.status || 'active'];
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="users-cell-user">
|
||||
<div className="users-cell-avatar">{u.username.charAt(0).toUpperCase()}</div>
|
||||
<span>{u.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="users-cell-mono">{u.email}</td>
|
||||
<td>{u.phone || '--'}</td>
|
||||
<td>
|
||||
<span className="users-badge" style={{ color: rn.color, background: rn.bg }}>
|
||||
{rn.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="users-level-badge">
|
||||
{u.membership?.planName || '免费版'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="users-cell-date">{new Date(u.createdAt).toLocaleDateString('zh-CN')}</td>
|
||||
<td>
|
||||
<span className="users-badge" style={{ color: st.color, background: st.bg }}>
|
||||
{st.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-cell-actions">
|
||||
<button className="users-action-btn" title="查看详情" onClick={() => setSelectedUser(u)}>
|
||||
<IconEye />
|
||||
</button>
|
||||
<button
|
||||
className={`users-action-btn ${u.status === 'active' ? 'danger' : 'success'}`}
|
||||
title={u.status === 'active' ? '封禁' : '解封'}
|
||||
onClick={() => handleToggleUserStatus(u.id)}
|
||||
>
|
||||
{u.status === 'active' ? <IconX /> : <IconCheck />}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="users-empty">无匹配的用户记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Detail Modal */}
|
||||
{selectedUser && (
|
||||
<UserDetailModal
|
||||
user={selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -0,0 +1,755 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
data: { label: string; config: Record<string, any> };
|
||||
}
|
||||
|
||||
interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
agent_id: string | null;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
variables: Record<string, any>;
|
||||
status: string;
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface NodeTemplate {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
config_schema: Record<string, any>;
|
||||
}
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
start: "#3B6D11",
|
||||
llm: "#534AB7",
|
||||
knowledge: "#185FA5",
|
||||
skill: "#0F6E56",
|
||||
condition: "#BA7517",
|
||||
http: "#D4537E",
|
||||
code: "#5F5E5A",
|
||||
agent: "#7C3AED",
|
||||
end: "#A32D2D",
|
||||
};
|
||||
|
||||
const NODE_LABELS: Record<string, string> = {
|
||||
start: "开始",
|
||||
llm: "LLM 调用",
|
||||
knowledge: "知识库检索",
|
||||
skill: "Skill 调用",
|
||||
condition: "条件分支",
|
||||
http: "HTTP 请求",
|
||||
code: "代码执行",
|
||||
agent: "Agent 节点",
|
||||
end: "结束",
|
||||
};
|
||||
|
||||
export default function Workflow() {
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<"list" | "editor">("list");
|
||||
const [currentWf, setCurrentWf] = useState<Workflow | null>(null);
|
||||
const [templates, setTemplates] = useState<NodeTemplate[]>([]);
|
||||
const [options, setOptions] = useState<Record<string, any[]>>({});
|
||||
const [selectedNode, setSelectedNode] = useState<WorkflowNode | null>(null);
|
||||
const [draggingNode, setDraggingNode] = useState<string | null>(null);
|
||||
const [connectingFrom, setConnectingFrom] = useState<string | null>(null);
|
||||
const [executeResult, setExecuteResult] = useState<any>(null);
|
||||
const [executeInput, setExecuteInput] = useState("");
|
||||
const [showExecute, setShowExecute] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [nodeStatus, setNodeStatus] = useState<Record<string, string>>({});
|
||||
const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 });
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
const api = (window as any).api;
|
||||
|
||||
// Load workflows
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get("/admin/workflows/");
|
||||
if (res.code === 0) setWorkflows(res.data.items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
try {
|
||||
const s = await api.get("/admin/workflows/stats");
|
||||
if (s.code === 0) setStats(s.data);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Load templates
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get("/admin/workflows/templates");
|
||||
if (res.code === 0) {
|
||||
setTemplates(Object.values(res.data.node_templates || {}));
|
||||
setOptions(res.data.options || {});
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWorkflows(); loadTemplates(); }, []);
|
||||
|
||||
// Create new workflow
|
||||
const handleCreate = async () => {
|
||||
const name = prompt("工作流名称:", "新工作流");
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await api.post("/admin/workflows/", {
|
||||
name,
|
||||
description: "",
|
||||
nodes: [
|
||||
{ id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } },
|
||||
{ id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } },
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
if (res.code === 0) {
|
||||
loadWorkflows();
|
||||
handleOpenEditor(res.data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Open editor
|
||||
const handleOpenEditor = (wf: Workflow) => {
|
||||
setCurrentWf(wf);
|
||||
setSelectedNode(null);
|
||||
setView("editor");
|
||||
};
|
||||
|
||||
// Save workflow
|
||||
const handleSave = async () => {
|
||||
if (!currentWf) return;
|
||||
try {
|
||||
const res = await api.put(`/admin/workflows/${currentWf.id}`, {
|
||||
name: currentWf.name,
|
||||
description: currentWf.description,
|
||||
nodes: currentWf.nodes,
|
||||
edges: currentWf.edges,
|
||||
variables: currentWf.variables,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setCurrentWf(res.data);
|
||||
alert("保存成功");
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Delete workflow
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确认删除此工作流?")) return;
|
||||
try {
|
||||
await api.del(`/admin/workflows/${id}`);
|
||||
loadWorkflows();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Toggle publish
|
||||
const handleToggle = async (wf: Workflow) => {
|
||||
const isActive = wf.status !== "published";
|
||||
try {
|
||||
await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`);
|
||||
loadWorkflows();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Add node to canvas
|
||||
const handleAddNode = (template: NodeTemplate) => {
|
||||
if (!currentWf) return;
|
||||
const id = `${template.type}-${Date.now()}`;
|
||||
const newNode: WorkflowNode = {
|
||||
id,
|
||||
type: template.type,
|
||||
position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 },
|
||||
data: {
|
||||
label: template.label,
|
||||
config: Object.fromEntries(
|
||||
Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""])
|
||||
),
|
||||
},
|
||||
};
|
||||
setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] });
|
||||
};
|
||||
|
||||
// Node drag
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
const node = currentWf!.nodes.find(n => n.id === nodeId);
|
||||
if (!node || !canvasRef.current) return;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
dragOffset.current = {
|
||||
x: e.clientX - rect.left - node.position.x,
|
||||
y: e.clientY - rect.top - node.position.y,
|
||||
};
|
||||
setDraggingNode(nodeId);
|
||||
setSelectedNode(node);
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingNode || !currentWf || !canvasRef.current) return;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - dragOffset.current.x;
|
||||
const y = e.clientY - rect.top - dragOffset.current.y;
|
||||
setCurrentWf({
|
||||
...currentWf,
|
||||
nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => { setDraggingNode(null); };
|
||||
|
||||
// Canvas click (deselect / cancel connecting)
|
||||
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedNode(null);
|
||||
setConnectingFrom(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Start connecting from a node
|
||||
const handleStartConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
setConnectingFrom(nodeId);
|
||||
};
|
||||
|
||||
// Complete connection to a node
|
||||
const handleEndConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
if (!connectingFrom || !currentWf) return;
|
||||
if (connectingFrom === nodeId) { setConnectingFrom(null); return; }
|
||||
// Check if edge already exists
|
||||
const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId);
|
||||
if (exists) { setConnectingFrom(null); return; }
|
||||
const newEdge: WorkflowEdge = {
|
||||
id: `e-${Date.now()}`,
|
||||
source: connectingFrom,
|
||||
target: nodeId,
|
||||
};
|
||||
setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] });
|
||||
setConnectingFrom(null);
|
||||
};
|
||||
|
||||
// Delete edge
|
||||
const handleDeleteEdge = (edgeId: string) => {
|
||||
if (!currentWf) return;
|
||||
setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) });
|
||||
};
|
||||
|
||||
// Delete selected node
|
||||
const handleDeleteNode = () => {
|
||||
if (!selectedNode || !currentWf) return;
|
||||
const nodeId = selectedNode.id;
|
||||
setCurrentWf({
|
||||
...currentWf,
|
||||
nodes: currentWf.nodes.filter(n => n.id !== nodeId),
|
||||
edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
|
||||
});
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
// Update node config
|
||||
const handleConfigChange = (key: string, value: any) => {
|
||||
if (!selectedNode || !currentWf) return;
|
||||
const updatedNodes = currentWf.nodes.map(n =>
|
||||
n.id === selectedNode.id
|
||||
? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } }
|
||||
: n
|
||||
);
|
||||
setCurrentWf({ ...currentWf, nodes: updatedNodes });
|
||||
setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null);
|
||||
};
|
||||
|
||||
// Execute workflow (supports streaming via WebSocket when available)
|
||||
const handleExecute = async () => {
|
||||
if (!currentWf) return;
|
||||
setExecuting(true);
|
||||
setExecuteResult(null);
|
||||
setNodeStatus({});
|
||||
|
||||
// Highlight running nodes in real-time
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = window.location.host;
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/app/api/admin/workflows/${currentWf.id}/execute-stream`;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let result: any = null;
|
||||
|
||||
try {
|
||||
// Connect WebSocket
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "node_start") {
|
||||
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "running" }));
|
||||
// Add to log
|
||||
setExecuteResult((prev: any) => ({
|
||||
...prev,
|
||||
log: [...(prev?.log || []), {
|
||||
node_id: msg.node_id,
|
||||
node_label: msg.node_label,
|
||||
node_type: msg.node_type,
|
||||
success: null,
|
||||
}],
|
||||
}));
|
||||
} else if (msg.type === "node_complete") {
|
||||
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "completed" }));
|
||||
// Update log entry
|
||||
setExecuteResult((prev: any) => {
|
||||
const log = (prev?.log || []).map((l: any) =>
|
||||
l.node_id === msg.node_id ? { ...l, success: true, output_preview: msg.output_preview } : l
|
||||
);
|
||||
return { ...prev, log };
|
||||
});
|
||||
} else if (msg.type === "node_error") {
|
||||
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "failed" }));
|
||||
setExecuteResult((prev: any) => {
|
||||
const log = (prev?.log || []).map((l: any) =>
|
||||
l.node_id === msg.node_id ? { ...l, success: false, error: msg.error } : l
|
||||
);
|
||||
return { ...prev, log };
|
||||
});
|
||||
} else if (msg.type === "workflow_complete") {
|
||||
result = msg.data;
|
||||
setExecuteResult(msg.data);
|
||||
} else if (msg.type === "workflow_error") {
|
||||
result = msg.data;
|
||||
setExecuteResult(msg.data);
|
||||
}
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
ws?.send(JSON.stringify({
|
||||
input_text: executeInput,
|
||||
token: (window as any).api?.getToken?.() || "",
|
||||
}));
|
||||
};
|
||||
|
||||
// Also call REST API as fallback
|
||||
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
||||
input_text: executeInput,
|
||||
});
|
||||
if (res.code === 0 && !result) {
|
||||
setExecuteResult(res.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Try REST API fallback
|
||||
try {
|
||||
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
||||
input_text: executeInput,
|
||||
});
|
||||
if (res.code === 0) setExecuteResult(res.data);
|
||||
} catch (e2) { console.error(e2); }
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
setTimeout(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.close();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// SVG edge path (bezier curve)
|
||||
const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => {
|
||||
const x1 = src.position.x + 90;
|
||||
const y1 = src.position.y + 25;
|
||||
const x2 = tgt.position.x;
|
||||
const y2 = tgt.position.y + 25;
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// LIST VIEW
|
||||
// ============================================================
|
||||
if (view === "list") {
|
||||
return (
|
||||
<div style={{ padding: "24px", color: "#1A1F36", minHeight: "100vh" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", color: "#1A1F36" }}>Agent 可视化编排</h2>
|
||||
<p style={{ margin: "4px 0 0", color: "#6B7280", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · LLM/知识库/Skill/条件/HTTP/代码/Agent 节点 · 并行多 Agent 协作</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} style={btnPrimary}>+ 新建工作流</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "16px", marginBottom: "24px" }}>
|
||||
{[
|
||||
{ label: "总工作流", value: stats.total, color: "#534AB7" },
|
||||
{ label: "草稿", value: stats.draft, color: "#BA7517" },
|
||||
{ label: "已发布", value: stats.published, color: "#3B6D11" },
|
||||
{ label: "已归档", value: stats.archived, color: "#5F5E5A" },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ ...statCard, borderLeft: `3px solid ${s.color}` }}>
|
||||
<div style={{ fontSize: "24px", fontWeight: 600, color: "#1A1F36" }}>{s.value}</div>
|
||||
<div style={{ fontSize: "12px", color: "#6B7280" }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workflow list */}
|
||||
<div style={{ background: "#FFFFFF", borderRadius: "12px", overflow: "hidden" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
||||
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
|
||||
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#6B7280", fontWeight: 500 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>加载中...</td></tr>
|
||||
) : workflows.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>暂无工作流,点击"新建工作流"开始</td></tr>
|
||||
) : workflows.map(wf => (
|
||||
<tr key={wf.id} style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<div style={{ color: "#1A1F36", fontWeight: 500 }}>{wf.name}</div>
|
||||
<div style={{ color: "#9CA3AF", fontSize: "12px" }}>{wf.description || "—"}</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<span style={statusBadge(wf.status)}>{wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"}</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px", color: "#9CA3AF" }}>{wf.nodes?.length || 0}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#9CA3AF" }}>v{wf.version}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#6B7280", fontSize: "12px" }}>{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"}</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<button onClick={() => handleOpenEditor(wf)} style={btnSmall}>编辑</button>
|
||||
<button onClick={() => handleToggle(wf)} style={{ ...btnSmall, marginLeft: "4px" }}>{wf.status === "published" ? "归档" : "发布"}</button>
|
||||
<button onClick={() => handleDelete(wf.id)} style={{ ...btnSmall, marginLeft: "4px", color: "#F09595" }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EDITOR VIEW
|
||||
// ============================================================
|
||||
if (!currentWf) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#F8F9FC", color: "#1A1F36" }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)", gap: "8px" }}>
|
||||
<button onClick={() => { setView("list"); loadWorkflows(); }} style={btnSmall}>← 返回</button>
|
||||
<input
|
||||
value={currentWf.name}
|
||||
onChange={e => setCurrentWf({ ...currentWf, name: e.target.value })}
|
||||
style={{ background: "transparent", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "6px", padding: "4px 12px", color: "#1A1F36", fontSize: "14px", width: "200px" }}
|
||||
/>
|
||||
<span style={{ color: "#9CA3AF", fontSize: "12px" }}>v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={handleSave} style={btnPrimary}>保存</button>
|
||||
<button onClick={() => setShowExecute(!showExecute)} style={{ ...btnSmall, borderColor: "#3B6D11" }}>执行</button>
|
||||
{selectedNode && <button onClick={handleDeleteNode} style={{ ...btnSmall, color: "#F09595" }}>删除节点</button>}
|
||||
</div>
|
||||
|
||||
{/* Execute panel */}
|
||||
{showExecute && (
|
||||
<div style={{ padding: "12px 16px", background: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#6B7280" }}>输入文本</label>
|
||||
<textarea
|
||||
value={executeInput}
|
||||
onChange={e => setExecuteInput(e.target.value)}
|
||||
placeholder="输入测试文本..."
|
||||
style={{ width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "6px", padding: "8px", color: "#1A1F36", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleExecute} style={{ ...btnPrimary, opacity: executing ? 0.6 : 1 }} disabled={executing}>
|
||||
{executing ? "执行中..." : "执行工作流"}
|
||||
</button>
|
||||
</div>
|
||||
{executing && (
|
||||
<div style={{ marginTop: "8px", fontSize: "12px", color: "#534AB7" }}>
|
||||
⏳ 工作流执行中...
|
||||
</div>
|
||||
)}
|
||||
{executeResult && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#6B7280", marginBottom: "4px" }}>执行日志:</div>
|
||||
{executeResult.log?.map((l: any, i: number) => (
|
||||
<div key={i} style={{ fontSize: "12px", padding: "4px 8px", background: l.success ? "rgba(59,109,17,0.15)" : "rgba(163,45,45,0.15)", borderRadius: "4px", marginBottom: "2px" }}>
|
||||
<span style={{ color: l.success ? "#97C459" : "#F09595" }}>[{l.node_type}]</span>{" "}
|
||||
<span style={{ color: "#9CA3AF" }}>{l.node_label}</span>
|
||||
{l.output_preview && <span style={{ color: "#9CA3AF" }}> → {l.output_preview.slice(0, 100)}</span>}
|
||||
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: "8px", padding: "8px", background: "#F8F9FC", borderRadius: "6px", border: "1px solid rgba(0,0,0,0.08)" }}>
|
||||
<div style={{ fontSize: "12px", color: "#6B7280", marginBottom: "4px" }}>最终输出:</div>
|
||||
<pre style={{ margin: 0, color: "#97C459", fontSize: "13px", whiteSpace: "pre-wrap" }}>{executeResult.output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main area: palette | canvas | config */}
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{/* Node palette */}
|
||||
<div style={{ width: "180px", background: "#FFFFFF", borderRight: "1px solid rgba(0,0,0,0.08)", padding: "12px", overflowY: "auto" }}>
|
||||
<div style={{ fontSize: "12px", color: "#9CA3AF", marginBottom: "8px", textTransform: "uppercase" }}>节点类型</div>
|
||||
{Object.entries(NODE_LABELS).map(([type, label]) => (
|
||||
<div
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const tmpl = templates.find(t => t.type === type);
|
||||
if (tmpl) handleAddNode(tmpl);
|
||||
else handleAddNode({ type, label, icon: "", color: NODE_COLORS[type], description: "", config_schema: {} });
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
marginBottom: "4px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
background: "#FFFFFF",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderLeft: `3px solid ${NODE_COLORS[type]}`,
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "#E8ECF4")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "#FFFFFF")}
|
||||
>
|
||||
<span style={{ color: "#1A1F36", fontSize: "13px" }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: "#F8F9FC",
|
||||
}}
|
||||
>
|
||||
{/* SVG for edges */}
|
||||
<svg style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||
{currentWf.edges.map(edge => {
|
||||
const src = currentWf.nodes.find(n => n.id === edge.source);
|
||||
const tgt = currentWf.nodes.find(n => n.id === edge.target);
|
||||
if (!src || !tgt) return null;
|
||||
return (
|
||||
<g key={edge.id} style={{ pointerEvents: "auto" }}>
|
||||
<path d={getEdgePath(src, tgt)} fill="none" stroke="#534AB7" strokeWidth={2} markerEnd="url(#arrowhead)" />
|
||||
<circle cx={(src.position.x + tgt.position.x + 90) / 2} cy={(src.position.y + tgt.position.y + 25) / 2} r={8} fill="#FFFFFF" stroke="#534AB7" strokeWidth={1} style={{ cursor: "pointer" }} onClick={() => handleDeleteEdge(edge.id)} />
|
||||
<text x={(src.position.x + tgt.position.x + 90) / 2} y={(src.position.y + tgt.position.y + 25) / 2 + 3} textAnchor="middle" fill="#F09595" fontSize="10" style={{ pointerEvents: "none" }}>x</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#534AB7" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{currentWf.nodes.map(node => {
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const status = nodeStatus[node.id];
|
||||
const baseColor = NODE_COLORS[node.type] || "#5F5E5A";
|
||||
const color = status === "running" ? "#58a6ff" : status === "completed" ? "#3fb950" : status === "failed" ? "#f85149" : baseColor;
|
||||
const borderWidth = isSelected || status ? 2 : 1;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
onMouseDown={e => handleNodeMouseDown(e, node.id)}
|
||||
onClick={e => { e.stopPropagation(); setSelectedNode(node); }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
width: "90px",
|
||||
minHeight: "50px",
|
||||
background: "#FFFFFF",
|
||||
border: `${borderWidth}px solid ${color}`,
|
||||
borderRadius: "8px",
|
||||
cursor: draggingNode === node.id ? "grabbing" : "grab",
|
||||
padding: "8px",
|
||||
textAlign: "center",
|
||||
boxShadow: isSelected ? `0 0 12px ${color}40` : "none",
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "11px", color, fontWeight: 600, marginBottom: "2px" }}>{NODE_LABELS[node.type] || node.type}</div>
|
||||
<div style={{ fontSize: "11px", color: "#9CA3AF" }}>{node.data.label}</div>
|
||||
{/* Connection handles */}
|
||||
<div
|
||||
onClick={e => connectingFrom ? handleEndConnect(e, node.id) : handleStartConnect(e, node.id)}
|
||||
style={{
|
||||
position: "absolute", right: -6, top: "50%", transform: "translateY(-50%)",
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
background: connectingFrom === node.id ? "#97C459" : "#534AB7",
|
||||
cursor: "pointer", border: "2px solid #1A1F36",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
onClick={e => connectingFrom && handleEndConnect(e, node.id)}
|
||||
style={{
|
||||
position: "absolute", left: -6, top: "50%", transform: "translateY(-50%)",
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
background: "#9CA3AF", cursor: "pointer", border: "2px solid #FFFFFF",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{connectingFrom && (
|
||||
<div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", background: "#534AB7", color: "#fff", padding: "4px 12px", borderRadius: "6px", fontSize: "12px" }}>
|
||||
点击目标节点的右端口完成连线
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config panel */}
|
||||
<div style={{ width: "280px", background: "#FFFFFF", borderLeft: "1px solid rgba(0,0,0,0.08)", padding: "16px", overflowY: "auto" }}>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600, color: "#1A1F36", marginBottom: "4px" }}>
|
||||
{NODE_LABELS[selectedNode.type] || selectedNode.type}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#9CA3AF", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
|
||||
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ fontSize: "12px", color: "#6B7280" }}>显示名称</label>
|
||||
<input
|
||||
value={selectedNode.data.label}
|
||||
onChange={e => {
|
||||
const updated = currentWf.nodes.map(n => n.id === selectedNode.id ? { ...n, data: { ...n.data, label: e.target.value } } : n);
|
||||
setCurrentWf({ ...currentWf, nodes: updated });
|
||||
setSelectedNode({ ...selectedNode, data: { ...selectedNode.data, label: e.target.value } });
|
||||
}}
|
||||
style={configInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config fields */}
|
||||
{Object.entries(templates.find(t => t.type === selectedNode.type)?.config_schema || {}).map(([key, schema]: [string, any]) => (
|
||||
<div key={key} style={{ marginBottom: "12px" }}>
|
||||
<label style={{ fontSize: "12px", color: "#6B7280" }}>{schema.label || key}</label>
|
||||
{schema.help && <div style={{ fontSize: "11px", color: "#9CA3AF", marginBottom: "2px" }}>{schema.help}</div>}
|
||||
{schema.type === "textarea" ? (
|
||||
<textarea
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={{ ...configInput, minHeight: "60px", resize: "vertical" }}
|
||||
/>
|
||||
) : schema.type === "select" && schema.options_from ? (
|
||||
<select
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={configInput}
|
||||
>
|
||||
<option value="">-- 选择 --</option>
|
||||
{(options[schema.options_from] || []).map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : schema.type === "select" && schema.options ? (
|
||||
<select
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={configInput}
|
||||
>
|
||||
<option value="">-- 选择 --</option>
|
||||
{schema.options.map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={schema.type === "number" ? "number" : "text"}
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, schema.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value)}
|
||||
style={configInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#9CA3AF", fontSize: "13px", textAlign: "center", marginTop: "40px" }}>
|
||||
选择一个节点查看配置
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Styles
|
||||
// ============================================================
|
||||
const btnPrimary: React.CSSProperties = {
|
||||
background: "#534AB7", color: "#fff", border: "none", borderRadius: "6px",
|
||||
padding: "6px 16px", cursor: "pointer", fontSize: "13px", fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnSmall: React.CSSProperties = {
|
||||
background: "transparent", color: "#9CA3AF", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "4px",
|
||||
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
|
||||
};
|
||||
|
||||
const statCard: React.CSSProperties = {
|
||||
background: "#FFFFFF", borderRadius: "8px", padding: "16px", border: "1px solid rgba(0,0,0,0.08)",
|
||||
};
|
||||
|
||||
const configInput: React.CSSProperties = {
|
||||
width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: "4px", padding: "6px 8px", color: "#1A1F36", fontSize: "13px",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
function statusBadge(status: string): React.CSSProperties {
|
||||
const colors: Record<string, string> = {
|
||||
published: "#3B6D11",
|
||||
draft: "#BA7517",
|
||||
archived: "#5F5E5A",
|
||||
};
|
||||
const bg = colors[status] || "#5F5E5A";
|
||||
return {
|
||||
background: `${bg}20`, color: bg, padding: "2px 8px",
|
||||
borderRadius: "4px", fontSize: "12px",
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user