fix: KB upload (assets.content column), KG conversion, achievement SQL index, card font color, markdown renderer

This commit is contained in:
qyyfw committed 2026-07-08 15:05:19 +08:00
1 parent 985e0548b0
commit ce0a7b4243
42 files changed
+29056

No files matched your search

+990
View File
@@ -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
+114
View File
@@ -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);
}
+493
View File
@@ -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;
+702
View File
@@ -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;
+207
View File
@@ -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;
+519
View File
@@ -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;
}
+799
View File
@@ -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;
+599
View File
@@ -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;
+209
View File
@@ -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;
+747
View File
@@ -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;
+385
View File
@@ -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;
+155
View File
@@ -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; }
+515
View File
@@ -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: [] }); }}>
&larr; 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
+538
View File
@@ -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;
+83
View File
@@ -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);
}
+693
View File
@@ -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;
+963
View File
@@ -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;
+405
View File
@@ -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;
+680
View File
@@ -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;
+766
View File
@@ -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;
+367
View File
@@ -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
+520
View File
@@ -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;
+546
View File
@@ -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;
+666
View File
@@ -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
+235
View File
@@ -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;
+584
View File
@@ -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;
}
}
+295
View File
@@ -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;
+755
View File
@@ -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