refactor: sync running-container source back to repo for reproducible docker compose build; remove dead code (orphan pipeline.py + 104 stale files from iterative dev) and drop unused app/ mirror dir
This commit is contained in:
1 parent
1346fbbd4b
commit
5d7f32db0a
138 files changed
+12846
-84248
No files matched your search
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>七虎AI - 智能AI能力集成平台</title>
|
||||
<title>Qihu-AIFlow - 智能自动化PaaS平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+36
-532
@@ -10,7 +10,7 @@ function getToken(): string | null {
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
export async function request<T>(
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ code: number; data: T; message: string }> {
|
||||
@@ -105,12 +105,12 @@ export const chatAPI = {
|
||||
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 }) =>
|
||||
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_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/`),
|
||||
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
|
||||
getModels: () => get<any[]>('/chat/models'),
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
|
||||
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),
|
||||
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),
|
||||
@@ -277,7 +277,6 @@ 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();
|
||||
@@ -309,17 +308,11 @@ export const adminAPI = {
|
||||
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'),
|
||||
// 能力类型 & 按能力默认模型
|
||||
getCapabilities: () => get<any[]>('/admin/models/capabilities'),
|
||||
getCapabilityDefaults: () => get<any>('/admin/models/capability-defaults'),
|
||||
setCapabilityDefault: (capability: string, modelId: string | null) =>
|
||||
put<any>('/admin/models/capability-defaults', { capability, model_id: modelId }),
|
||||
// 网站设置
|
||||
getSiteSettings: () => get<any>('/admin/settings/site'),
|
||||
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
|
||||
@@ -365,25 +358,41 @@ export const adminAPI = {
|
||||
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}`),
|
||||
addPlan: (data: any) => post<any>('/admin/membership/plans', data),
|
||||
updatePlan: (id: string, data: any) => put<any>(`/admin/membership/plans/${id}`, data),
|
||||
deletePlan: (id: string) => del<any>(`/admin/membership/plans/${id}`),
|
||||
getSubscriptions: (page?: number) => get<any>(`/admin/membership/subscriptions${page ? `?page=${page}` : ''}`),
|
||||
getSubscriptions: () => get<any[]>('/admin/membership/subscriptions'),
|
||||
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}` : ''}`);
|
||||
// 广告管理
|
||||
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}`),
|
||||
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
|
||||
// 应用市场审核
|
||||
getPendingApps: () => get<any[]>('/admin/market/apps/pending'),
|
||||
approveApp: (id: string) => put<any>(`/admin/market/apps/${id}/approve`),
|
||||
rejectApp: (id: string) => put<any>(`/admin/market/apps/${id}/reject`),
|
||||
getAdminApps: () => get<any[]>('/admin/market/apps'),
|
||||
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
||||
getCoupons: () => get<any[]>('/admin/market/coupons'),
|
||||
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
|
||||
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
|
||||
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
|
||||
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
|
||||
// 日志与风控
|
||||
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/operations${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()}` : ''}`);
|
||||
},
|
||||
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)); });
|
||||
@@ -430,301 +439,8 @@ export const adminAPI = {
|
||||
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),
|
||||
getAdminStorageUsage: (id: string) => get<any>(`/admin/storage/${id}/usage`),
|
||||
// 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 = {
|
||||
// 驾驶舱面板
|
||||
@@ -794,216 +510,4 @@ export const evolutionAPI = {
|
||||
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}`),
|
||||
// 个人微信(iLink 协议)原生适配器
|
||||
getWechatConfig: () => get('/admin/im-bot/wechat'),
|
||||
wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'),
|
||||
wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`),
|
||||
getWechatStatus: () => get('/admin/im-bot/wechat/status'),
|
||||
disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'),
|
||||
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()}` : ''}`);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
|
||||
export const imUserAPI = {
|
||||
getStatus: () => get<any>('/im/wechat/status'),
|
||||
qrcode: () => post<any>('/im/wechat/qrcode'),
|
||||
qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
|
||||
disconnect: () => post<any>('/im/wechat/disconnect'),
|
||||
history: () => get<any>('/im/wechat/history'),
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,232 +1,121 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import './AdminLayout.css';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
icon: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const adminNavGroups: NavGroup[] = [
|
||||
{
|
||||
title: '驾驶舱',
|
||||
icon: 'dashboard',
|
||||
items: [
|
||||
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
||||
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI 能力',
|
||||
icon: 'agents',
|
||||
items: [
|
||||
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
|
||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||
{ path: '/admin/models', icon: 'models', label: '大模型库' },
|
||||
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
||||
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
|
||||
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP 服务' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '应用与市场',
|
||||
icon: 'market',
|
||||
items: [
|
||||
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
|
||||
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
|
||||
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '用户运营',
|
||||
icon: 'users',
|
||||
items: [
|
||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
||||
{ path: '/admin/membership', icon: 'membership', label: '会员订阅' },
|
||||
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
|
||||
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '商业变现',
|
||||
icon: 'billing',
|
||||
items: [
|
||||
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
|
||||
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
|
||||
{ path: '/admin/services', icon: 'services', label: '增值服务' },
|
||||
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
|
||||
{ path: '/admin/assets', icon: 'assets', label: '资产管理' },
|
||||
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
icon: 'settings',
|
||||
items: [
|
||||
{ path: '/admin/settings', icon: 'settings', label: '网站设置' },
|
||||
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
|
||||
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
||||
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
||||
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '开发与集成',
|
||||
icon: 'developer',
|
||||
items: [
|
||||
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
|
||||
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
|
||||
],
|
||||
},
|
||||
const adminNavItems = [
|
||||
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
||||
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
|
||||
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
|
||||
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
|
||||
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
|
||||
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
|
||||
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
|
||||
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
|
||||
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
|
||||
];
|
||||
|
||||
const adminIconPaths: Record<string, JSX.Element> = {
|
||||
dashboard: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
models: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
|
||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
payment: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
|
||||
services: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
membership: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
ads: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
|
||||
market: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
logs: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/><polyline points="10 9 9 9 8 9"/></svg>,
|
||||
agents: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>,
|
||||
skills: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||
code: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||
distro: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
||||
privacy: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>,
|
||||
promotion: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="7"/><path d="M8.5 12.5L6 21l6-2 6 2-2.5-8.5"/><circle cx="12" cy="8" r="3"/></svg>,
|
||||
achievement: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>,
|
||||
tenant: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21V8l9-5 9 5v13"/><path d="M9 21v-6h6v6"/><path d="M3 13h18"/></svg>,
|
||||
billing: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>,
|
||||
analytics: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/><line x1="3" y1="20" x2="21" y2="20"/></svg>,
|
||||
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
|
||||
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
||||
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
|
||||
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
|
||||
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
||||
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
|
||||
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
|
||||
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
|
||||
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
|
||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/><polyline points="10 9 9 9 8 9"/></svg>,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Default: all groups collapsed. Auto-expand the group containing the active route.
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>();
|
||||
const currentHash = window.location.hash;
|
||||
for (const group of adminNavGroups) {
|
||||
const isActive = group.items.some(item =>
|
||||
item.exact ? currentHash === `#${item.path}` : currentHash.startsWith(`#${item.path}`)
|
||||
);
|
||||
if (isActive) {
|
||||
initial.add(group.title);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(title)) next.delete(title);
|
||||
else next.add(title);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleVisitFrontend = () => {
|
||||
const url = window.location.origin + window.location.pathname + '#/chat';
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
if (!user || user.role !== 'admin') {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<aside className="admin-sidebar">
|
||||
{/* Admin Sidebar */}
|
||||
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="admin-sidebar-header">
|
||||
<h1 className="admin-logo">七虎AI</h1>
|
||||
<span className="admin-subtitle">管理控制台</span>
|
||||
<div className="sidebar-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
|
||||
</div>
|
||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleVisitFrontend} className="admin-frontend-link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<span>访问前台</span>
|
||||
</button>
|
||||
<nav className="sidebar-nav">
|
||||
{adminNavGroups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.title);
|
||||
const hasActiveChild = group.items.some(item =>
|
||||
item.exact ? window.location.hash === `#${item.path}` : window.location.hash.startsWith(`#${item.path}`)
|
||||
);
|
||||
return (
|
||||
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
|
||||
<div
|
||||
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
>
|
||||
<span className="nav-group-chevron-wrap">
|
||||
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
|
||||
{adminIconPaths.chevron}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nav-group-title">{group.title}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="nav-group-items">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.exact}
|
||||
className={({ isActive }) =>
|
||||
`nav-item ${isActive ? 'nav-item-active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
|
||||
<span className="nav-item-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<nav className="admin-nav">
|
||||
{adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.exact}
|
||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="admin-sidebar-footer">
|
||||
<div className="admin-user-info">
|
||||
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
|
||||
<div className="admin-user-detail">
|
||||
<div className="admin-user-name">{user?.username || 'admin'}</div>
|
||||
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
|
||||
<NavLink to="/chat" className="admin-nav-item">
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">返回前台</span>}
|
||||
</NavLink>
|
||||
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">退出登录</span>}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="admin-version-info">
|
||||
Qihu-AIFlow v2.0.0
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Admin Main Content */}
|
||||
<main className="admin-main">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-left">
|
||||
<span className="admin-welcome">欢迎,{user.username}</span>
|
||||
</div>
|
||||
<div className="admin-topbar-right">
|
||||
<a href="#/chat" className="admin-topbar-action">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>返回前台</span>
|
||||
</a>
|
||||
<div className="admin-user-tag">
|
||||
<div className="user-avatar-sm admin-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="admin-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="admin-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/* ===== Front Layout V3 - Light Theme ===== */
|
||||
/* ===== Front Layout ===== */
|
||||
.front-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #F5F7FC;
|
||||
color: #1A1F36;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.front-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFE 100%);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--color-sidebar-bg);
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width var(--transition-slow), min-width var(--transition-slow);
|
||||
@@ -31,7 +29,7 @@
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg) var(--space-lg);
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
@@ -43,7 +41,7 @@
|
||||
.sidebar-brand {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #1A1F36;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -55,14 +53,13 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
color: #8892B0;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: #8892B0;
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
@@ -71,7 +68,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-sm);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -80,23 +77,14 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-group-title {
|
||||
padding: 14px var(--space-md) 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: 10px var(--space-md);
|
||||
padding: var(--space-md) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
color: #8892B0;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
@@ -105,13 +93,13 @@
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
color: #1A1F36;
|
||||
background: var(--color-sidebar-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
color: #8B85FF;
|
||||
background: var(--color-sidebar-active);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@@ -127,30 +115,10 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Active dot indicator */
|
||||
.nav-active-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-active-dot {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6C63FF;
|
||||
box-shadow: 0 0 8px rgba(108, 99, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
padding: var(--space-md);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-footer .nav-item {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.user-info-mini {
|
||||
@@ -158,14 +126,13 @@
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-avatar-sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, #6C63FF, #A78BFA);
|
||||
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -184,21 +151,16 @@
|
||||
.user-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: #1A1F36;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-plan {
|
||||
font-size: var(--text-xs);
|
||||
color: #8B85FF;
|
||||
color: var(--color-accent);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: #EF4444 !important;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.front-main {
|
||||
flex: 1;
|
||||
@@ -206,12 +168,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #F5F7FC;
|
||||
}
|
||||
|
||||
/* Forward-slash route for apps - hidden default route */
|
||||
.front-main > * {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -231,11 +187,3 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Home & Admin links */
|
||||
.nav-home-link {
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -1,183 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import './FrontLayout.css';
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
title: '智能体',
|
||||
items: [
|
||||
{ path: '/chat', icon: 'chat', label: 'AI聊天' },
|
||||
{ path: '/knowledge', icon: 'knowledge', label: '知识库' },
|
||||
{ path: '/market', icon: 'market', label: '技能市场' },
|
||||
{ path: '/evolution', icon: 'evolution', label: 'AI自进化' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'IM Bot',
|
||||
items: [
|
||||
{ path: '/channels', icon: 'channel', label: '频道管理' },
|
||||
{ path: '/wechat-bot', icon: 'wechat', label: '微信机器人' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '资源中心',
|
||||
items: [
|
||||
{ path: '/assets', icon: 'assets', label: '资产文件' },
|
||||
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
|
||||
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const bottomItems = [
|
||||
{ path: '/profile', icon: 'profile', label: '个人中心' },
|
||||
{ path: '/notifications', icon: 'notify', label: '消息通知' },
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, JSX.Element> = {
|
||||
chat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
|
||||
channel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="9" height="9" rx="2"/><rect x="13" y="2" width="9" height="9" rx="2"/><rect x="2" y="13" width="9" height="9" rx="2"/><rect x="13" y="13" width="5" height="5" rx="1.5"/><path d="M18 18v3"/><path d="M19.5 16.5v6"/></svg>,
|
||||
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
|
||||
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
|
||||
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
|
||||
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
|
||||
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
|
||||
wechat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><circle cx="9" cy="11" r="1"/><circle cx="15" cy="11" r="1"/></svg>,
|
||||
};
|
||||
|
||||
const FrontLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="front-layout">
|
||||
{/* Sidebar */}
|
||||
<aside className={`front-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="flGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6C63FF"/>
|
||||
<stop offset="100%" stopColor="#A78BFA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#flGrad)"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
{!collapsed && <span className="sidebar-brand">七虎AI</span>}
|
||||
</div>
|
||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{collapsed ? <><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></> : <><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></>}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="sidebar-nav">
|
||||
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
|
||||
<span className="nav-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-item-label">返回首页</span>}
|
||||
</NavLink>
|
||||
{navGroups.map(group => (
|
||||
<div className="nav-section" key={group.title}>
|
||||
{!collapsed && <div className="nav-group-title">{group.title}</div>}
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{iconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
{!collapsed && <span className="nav-active-dot" />}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-section nav-bottom">
|
||||
{bottomItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{iconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
{!collapsed && <span className="nav-active-dot" />}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info-mini">
|
||||
<div className="user-avatar-sm">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="user-details">
|
||||
<span className="user-name">{user.username}</span>
|
||||
<span className="user-plan">{user.membership?.planName || '免费版'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user.role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
title="管理后台"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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"/>
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">管理后台</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
<button
|
||||
className="nav-item logout-btn"
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
title="退出登录"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">退出登录</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="front-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrontLayout;
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import './FrontLayout.css';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/chat', icon: 'chat', label: 'AI聊天' },
|
||||
{ path: '/channels', icon: 'channel', label: '频道' },
|
||||
{ path: '/knowledge', icon: 'knowledge', label: '知识库' },
|
||||
{ path: '/market', icon: 'market', label: '应用市场' },
|
||||
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
|
||||
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
|
||||
{ path: '/assets', icon: 'assets', label: '资产' },
|
||||
{ path: '/evolution', icon: 'evolution', label: 'AI进化' },
|
||||
];
|
||||
|
||||
const bottomItems = [
|
||||
{ path: '/profile', icon: 'profile', label: '个人中心' },
|
||||
{ path: '/notifications', icon: 'notify', label: '消息通知' },
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, JSX.Element> = {
|
||||
chat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
|
||||
channel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
|
||||
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>,
|
||||
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
|
||||
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
|
||||
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
|
||||
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
|
||||
};
|
||||
|
||||
const FrontLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="front-layout">
|
||||
{/* Sidebar */}
|
||||
<aside className={`front-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
{!collapsed && <span className="sidebar-brand">Qihu-AIFlow</span>}
|
||||
</div>
|
||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">{iconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-section nav-bottom">
|
||||
{bottomItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">{iconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info-mini">
|
||||
<div className="user-avatar-sm">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="user-details">
|
||||
<span className="user-name">{user.username}</span>
|
||||
<span className="user-plan">{user.membership?.planName || '免费版'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user.role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
title="返回后台"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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"/>
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">返回后台</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
<button
|
||||
className="nav-item logout-btn"
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
title="退出登录"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">退出登录</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="front-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrontLayout;
|
||||
@@ -1,491 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { request, adminAPI } from '@/api';
|
||||
|
||||
// ==================== Types ====================
|
||||
interface UserOverview {
|
||||
user: { id: string; username: string; email: string; avatar: string | null; role: string; membership_level: number; membership_expire_at: string | null; created_at: string; };
|
||||
token_stats: { total_tokens: number; prompt_tokens: number; completion_tokens: number; call_count: number; };
|
||||
daily_usage: { date: string; calls: number; tokens: number; }[];
|
||||
tenants: { id: string; name: string; slug: string; status: string; role: string; }[];
|
||||
recent_transactions: { id: string; type: string; amount: number; tokens: number; description: string; status: string; created_at: string; }[];
|
||||
api_key_count: number;
|
||||
}
|
||||
interface ApiKey { id: string; key_name: string; key_prefix: string; status: string; created_at: string; last_used_at: string | null; }
|
||||
interface Package { id: string; name: string; description: string; tokens: number; price: number; original_price: number | null; bonus_tokens: number; is_popular: boolean; }
|
||||
interface Tenant { id: string; name: string; slug: string; description: string; status: string; max_members: number; max_storage_mb: number; max_api_calls_per_day: number; role: string; joined_at: string; member_count: number; }
|
||||
interface UsageData { daily: { date: string; calls: number; tokens: number; prompt_tokens: number; completion_tokens: number; }[]; model_distribution: { model: string; calls: number; tokens: number; }[]; total: { calls: number; tokens: number; }; days: number; }
|
||||
|
||||
const API_BASE = '/app/api/portal';
|
||||
|
||||
// ==================== Component ====================
|
||||
export default function UserPortal() {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: '概览', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ key: 'apikeys', label: 'API密钥', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ key: 'recharge', label: '充值中心', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
{ key: 'tenants', label: '我的租户', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5' },
|
||||
{ key: 'analytics', label: '用量分析', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#e6edf3', padding: '24px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '28px', marginBottom: '24px' }}>用户门户</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div style={{ display: 'flex', gap: '4px', borderBottom: '1px solid #30363d', marginBottom: '24px' }}>
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '10px 20px', background: 'none', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #58a6ff' : '2px solid transparent',
|
||||
color: activeTab === tab.key ? '#58a6ff' : '#8b949e', cursor: 'pointer', fontSize: '14px', fontWeight: 600,
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && <OverviewTab />}
|
||||
{activeTab === 'apikeys' && <ApiKeysTab />}
|
||||
{activeTab === 'recharge' && <RechargeTab />}
|
||||
{activeTab === 'tenants' && <TenantsTab />}
|
||||
{activeTab === 'analytics' && <AnalyticsTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Overview Tab ====================
|
||||
function OverviewTab() {
|
||||
const [data, setData] = useState<UserOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<any>(`${API_BASE}/overview`).then(res => {
|
||||
setData(res.data || res);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>加载中...</div>;
|
||||
if (!data) return <div style={{ padding: '40px', textAlign: 'center', color: '#f85149' }}>加载失败</div>;
|
||||
|
||||
const cards = [
|
||||
{ label: '本月Token用量', value: data.token_stats.total_tokens.toLocaleString(), color: '#58a6ff' },
|
||||
{ label: 'API调用次数', value: data.token_stats.call_count.toLocaleString(), color: '#3fb950' },
|
||||
{ label: 'API密钥数', value: data.api_key_count.toString(), color: '#bc8cff' },
|
||||
{ label: '所属租户', value: data.tenants.length.toString(), color: '#f0883e' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{cards.map((c, i) => (
|
||||
<div key={i} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||
<div style={{ color: c.color, fontSize: '28px', fontWeight: 700 }}>{c.value}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: '13px', marginTop: '4px' }}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
{/* Daily Usage Chart */}
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>近7天用量</h3>
|
||||
{data.daily_usage.length > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '120px' }}>
|
||||
{data.daily_usage.map((d, i) => {
|
||||
const maxTokens = Math.max(...data.daily_usage.map(x => x.tokens), 1);
|
||||
const height = (d.tokens / maxTokens) * 100;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ width: '100%', height: `${height}%`, background: '#58a6ff', borderRadius: '4px 4px 0 0', minHeight: '4px' }} />
|
||||
<div style={{ fontSize: '10px', color: '#8b949e', marginTop: '4px' }}>{d.date.slice(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>最近交易</h3>
|
||||
{data.recent_transactions.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{data.recent_transactions.map(t => (
|
||||
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #21262d' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px' }}>{t.description}</div>
|
||||
<div style={{ fontSize: '11px', color: '#8b949e' }}>{t.created_at}</div>
|
||||
</div>
|
||||
<div style={{ color: t.amount >= 0 ? '#3fb950' : '#f85149', fontWeight: 600, fontSize: '14px' }}>
|
||||
{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无交易</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenants */}
|
||||
{data.tenants.length > 0 && (
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginTop: '16px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>我的租户</h3>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
{data.tenants.map(t => (
|
||||
<div key={t.id} style={{ padding: '12px 16px', background: '#21262d', borderRadius: '8px', flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{t.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#8b949e' }}>角色: {t.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== API Keys Tab ====================
|
||||
function ApiKeysTab() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<any>(`${API_BASE}/api-keys`).then(res => {
|
||||
setKeys(res.data || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const createKey = () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
request<any>(`${API_BASE}/api-keys/create?key_name=${encodeURIComponent(newKeyName)}`, { method: 'POST' }).then(res => {
|
||||
setNewKey(res.data?.api_key || null);
|
||||
setNewKeyName('');
|
||||
setShowCreate(false);
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
const revokeKey = (id: string) => {
|
||||
if (!confirm('确定撤销此密钥?')) return;
|
||||
request<any>(`${API_BASE}/api-keys/${id}`, { method: 'DELETE' }).then(() => load());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '20px' }}>API 密钥管理</h2>
|
||||
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
|
||||
创建密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newKey && (
|
||||
<div style={{ background: '#1a2332', border: '1px solid #58a6ff', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ color: '#58a6ff', fontWeight: 600, marginBottom: '8px' }}>密钥已创建 - 请立即保存</div>
|
||||
<code style={{ display: 'block', padding: '8px', background: '#0d1117', borderRadius: '4px', wordBreak: 'break-all', fontSize: '13px' }}>{newKey}</code>
|
||||
<button onClick={() => setNewKey(null)} style={{ marginTop: '8px', padding: '4px 12px', background: '#30363d', color: '#e6edf3', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>我已保存</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<input value={newKeyName} onChange={e => setNewKeyName(e.target.value)} placeholder="密钥名称(如:生产环境)"
|
||||
style={{ width: '300px', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginRight: '8px' }} />
|
||||
<button onClick={createKey} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div> : (
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>名称</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>密钥前缀</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>状态</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>创建时间</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无API密钥</td></tr>
|
||||
) : keys.map(k => (
|
||||
<tr key={k.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: '12px' }}>{k.key_name}</td>
|
||||
<td style={{ padding: '12px' }}><code style={{ color: '#f0883e' }}>{k.key_prefix}...</code></td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{ padding: '2px 8px', borderRadius: '12px', fontSize: '12px', background: k.status === 'active' ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', color: k.status === 'active' ? '#3fb950' : '#f85149' }}>
|
||||
{k.status === 'active' ? '活跃' : '已撤销'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#8b949e' }}>{k.created_at}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{k.status === 'active' && <button onClick={() => revokeKey(k.id)} style={{ padding: '4px 10px', background: 'rgba(248,81,73,0.1)', color: '#f85149', border: '1px solid rgba(248,81,73,0.3)', borderRadius: '4px', cursor: 'pointer' }}>撤销</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Recharge Tab ====================
|
||||
function RechargeTab() {
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<any>(`${API_BASE}/billing/packages`).then(res => {
|
||||
setPackages(res.data || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '20px', marginBottom: '24px' }}>充值中心</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
|
||||
{packages.map(pkg => (
|
||||
<div key={pkg.id} style={{
|
||||
background: '#161b22', border: pkg.is_popular ? '2px solid #58a6ff' : '1px solid #30363d',
|
||||
borderRadius: '12px', padding: '24px', position: 'relative',
|
||||
}}>
|
||||
{pkg.is_popular && <div style={{ position: 'absolute', top: '-10px', left: '50%', transform: 'translateX(-50%)', background: '#58a6ff', color: '#0d1117', padding: '2px 12px', borderRadius: '10px', fontSize: '12px', fontWeight: 600 }}>推荐</div>}
|
||||
<h3 style={{ fontSize: '18px', marginBottom: '8px' }}>{pkg.name}</h3>
|
||||
<p style={{ color: '#8b949e', fontSize: '13px', minHeight: '40px' }}>{pkg.description}</p>
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<span style={{ fontSize: '32px', fontWeight: 700, color: '#58a6ff' }}>¥{pkg.price}</span>
|
||||
{pkg.original_price && <span style={{ marginLeft: '8px', color: '#8b949e', textDecoration: 'line-through' }}>¥{pkg.original_price}</span>}
|
||||
</div>
|
||||
<div style={{ color: '#3fb950', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{(pkg.tokens / 10000).toFixed(0)} 万 Token{pkg.bonus_tokens > 0 && ` +${(pkg.bonus_tokens / 10000).toFixed(0)}万 赠送`}
|
||||
</div>
|
||||
<button style={{
|
||||
width: '100%', padding: '10px', background: pkg.is_popular ? '#58a6ff' : '#238636',
|
||||
color: pkg.is_popular ? '#0d1117' : '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
|
||||
}}>立即购买</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>交易记录</h3>
|
||||
<TransactionsList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionsList() {
|
||||
const [txns, setTxns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<any>(`${API_BASE}/billing/transactions`).then(res => {
|
||||
setTxns(res.data?.items || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>加载中...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
{['描述', '类型', '金额', 'Token', '状态', '时间'].map(h => <th key={h} style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e' }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{txns.length === 0 ? <tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无交易记录</td></tr> : txns.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: '12px' }}>{t.description}</td>
|
||||
<td style={{ padding: '12px' }}>{t.type}</td>
|
||||
<td style={{ padding: '12px', color: t.amount >= 0 ? '#3fb950' : '#f85149' }}>{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}</td>
|
||||
<td style={{ padding: '12px' }}>{t.tokens?.toLocaleString() || '-'}</td>
|
||||
<td style={{ padding: '12px' }}><span style={{ color: t.status === 'completed' ? '#3fb950' : '#d29922' }}>{t.status}</span></td>
|
||||
<td style={{ padding: '12px', color: '#8b949e' }}>{t.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Tenants Tab ====================
|
||||
function TenantsTab() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<any>(`${API_BASE}/tenants`).then(res => {
|
||||
setTenants(res.data || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const create = () => {
|
||||
if (!name.trim() || !slug.trim()) return;
|
||||
request<any>(`${API_BASE}/tenants/create?name=${encodeURIComponent(name)}&slug=${encodeURIComponent(slug)}&description=${encodeURIComponent(desc)}`, { method: 'POST' }).then(() => {
|
||||
setShowCreate(false); setName(''); setSlug(''); setDesc('');
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '20px' }}>我的租户</h2>
|
||||
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>创建租户</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '20px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="租户名称" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
|
||||
<input value={slug} onChange={e => setSlug(e.target.value)} placeholder="标识(如:my-company)" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
|
||||
</div>
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(选填)" style={{ width: '100%', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginBottom: '12px' }} />
|
||||
<button onClick={create} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div style={{ color: '#8b949e', padding: '40px', textAlign: 'center' }}>加载中...</div> : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
{tenants.length === 0 ? <div style={{ color: '#8b949e', gridColumn: '1/3', textAlign: 'center', padding: '40px' }}>暂无租户,点击上方创建</div> : tenants.map(t => (
|
||||
<div key={t.id} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<h3 style={{ fontSize: '18px' }}>{t.name}</h3>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: t.role === 'owner' ? 'rgba(188,140,255,0.15)' : 'rgba(88,166,255,0.15)', color: t.role === 'owner' ? '#bc8cff' : '#58a6ff' }}>{t.role}</span>
|
||||
</div>
|
||||
<p style={{ color: '#8b949e', fontSize: '13px', marginBottom: '12px' }}>{t.description || '暂无描述'}</p>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '13px', color: '#8b949e' }}>
|
||||
<span>成员: {t.member_count}/{t.max_members}</span>
|
||||
<span>存储: {(t.max_storage_mb / 1024).toFixed(0)}GB</span>
|
||||
<span>API/日: {t.max_api_calls_per_day.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Analytics Tab ====================
|
||||
function AnalyticsTab() {
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [days, setDays] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<any>(`${API_BASE}/analytics/usage?days=${days}`).then(res => {
|
||||
setData(res.data);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '20px' }}>用量分析</h2>
|
||||
<select value={days} onChange={e => setDays(Number(e.target.value))} style={{ padding: '8px 12px', background: '#161b22', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }}>
|
||||
<option value={7}>最近7天</option>
|
||||
<option value={14}>最近14天</option>
|
||||
<option value={30}>最近30天</option>
|
||||
<option value={90}>最近90天</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>加载中...</div> : data ? (
|
||||
<div>
|
||||
{/* Total Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: '#58a6ff' }}>{data.total.calls.toLocaleString()}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: '13px' }}>API调用总数</div>
|
||||
</div>
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: '#3fb950' }}>{data.total.tokens.toLocaleString()}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: '13px' }}>Token消耗总数</div>
|
||||
</div>
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: '#bc8cff' }}>{data.daily.length}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: '13px' }}>活跃天数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Usage Chart */}
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>每日用量趋势</h3>
|
||||
{data.daily.length > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '4px', height: '150px' }}>
|
||||
{data.daily.map((d, i) => {
|
||||
const max = Math.max(...data.daily.map(x => x.tokens), 1);
|
||||
const h = (d.tokens / max) * 100;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }} title={`${d.date}: ${d.tokens} tokens`}>
|
||||
<div style={{ width: '100%', height: `${h}%`, background: 'linear-gradient(180deg, #58a6ff, #1f6feb)', borderRadius: '4px 4px 0 0', minHeight: '2px' }} />
|
||||
{data.daily.length <= 14 && <div style={{ fontSize: '9px', color: '#8b949e', marginTop: '2px', transform: 'rotate(-45deg)' }}>{d.date.slice(5)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||
</div>
|
||||
|
||||
{/* Model Distribution */}
|
||||
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>模型使用分布</h3>
|
||||
{data.model_distribution.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{data.model_distribution.map((m, i) => {
|
||||
const maxTokens = Math.max(...data.model_distribution.map(x => x.tokens), 1);
|
||||
const pct = (m.tokens / maxTokens) * 100;
|
||||
const colors = ['#58a6ff', '#3fb950', '#bc8cff', '#f0883e', '#d29922'];
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: '120px', fontSize: '13px' }}>{m.model}</div>
|
||||
<div style={{ flex: 1, height: '20px', background: '#21262d', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: colors[i % colors.length] }} />
|
||||
</div>
|
||||
<div style={{ width: '100px', fontSize: '12px', color: '#8b949e', textAlign: 'right' }}>{m.tokens.toLocaleString()} tokens</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : <div style={{ color: '#f85149', textAlign: 'center', padding: '40px' }}>加载失败</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,701 +0,0 @@
|
||||
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>
|
||||
</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;
|
||||
@@ -1,207 +0,0 @@
|
||||
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(auto-fill, minmax(200px, 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;
|
||||
@@ -1,519 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
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;
|
||||
@@ -1,209 +0,0 @@
|
||||
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;
|
||||
@@ -1,747 +0,0 @@
|
||||
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;
|
||||
@@ -1,385 +1,227 @@
|
||||
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;
|
||||
import React, { useState, useEffect, useMemo } 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>
|
||||
);
|
||||
|
||||
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> },
|
||||
];
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
|
||||
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, logsRes, auditRes] = await Promise.all([
|
||||
adminAPI.getStats(),
|
||||
adminAPI.getRecentLogs(),
|
||||
adminAPI.getRecentAudit(),
|
||||
]);
|
||||
if (statsRes.data) setStats(statsRes.data);
|
||||
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
||||
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
||||
} 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 >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||
return n.toLocaleString('en-US');
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', 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: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', 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.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
||||
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></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 */}
|
||||
<div className="admin-stats">
|
||||
{statCards.map((s, i) => (
|
||||
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
|
||||
background: s.bg, color: s.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
<div className="admin-stat-value">{s.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Platform Cumulative Token Stats */}
|
||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
<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消耗
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
|
||||
{[
|
||||
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
|
||||
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
|
||||
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
|
||||
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
||||
{item.value}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Entry Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
|
||||
{quickEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
onClick={() => navigate(entry.path)}
|
||||
style={{
|
||||
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
|
||||
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-accent)';
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Operation Logs */}
|
||||
<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>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>
|
||||
</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;
|
||||
@@ -1,155 +0,0 @@
|
||||
.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; }
|
||||
@@ -1,515 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import './Developer.css';
|
||||
|
||||
const API_BASE = '/app/api';
|
||||
|
||||
interface Webhook {
|
||||
id: string; name: string; url: string; events: string[];
|
||||
has_secret: boolean; headers: Record<string, string>; status: string;
|
||||
last_triggered_at: string | null; last_status: string | null;
|
||||
total_deliveries: number; success_count: number; fail_count: number;
|
||||
success_rate: number; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
interface WebhookEvent {
|
||||
value: string; label: string; desc: string;
|
||||
}
|
||||
|
||||
interface WebhookDelivery {
|
||||
id: string; event: string; status: string; response_code: number | null;
|
||||
response_time_ms: number | null; attempts: number; error_message: string | null;
|
||||
payload: any; response_body: string; created_at: string;
|
||||
}
|
||||
|
||||
interface SDKPackage {
|
||||
language: string; name: string; version: string; description: string;
|
||||
install_cmd: string; download_url: string; github_url: string;
|
||||
min_version: string; features: string[]; code_example: string;
|
||||
}
|
||||
|
||||
interface ApiDocSection {
|
||||
section: string; icon: string; description: string;
|
||||
endpoints: Array<{
|
||||
method: string; path: string; title: string; description: string;
|
||||
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
|
||||
example_request: string; example_response: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
|
||||
|
||||
async function api(path: string, options?: RequestInit) {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 307) {
|
||||
const location = res.headers.get('Location');
|
||||
if (location) return api(location.replace(API_BASE, ''), options);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function AdminDeveloperTools() {
|
||||
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Webhooks state
|
||||
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
|
||||
const [showWebhookModal, setShowWebhookModal] = useState(false);
|
||||
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
||||
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
|
||||
// SDK state
|
||||
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
|
||||
const [selectedSdk, setSelectedSdk] = useState<string>('python');
|
||||
|
||||
// API Docs state
|
||||
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
|
||||
const loadWebhooks = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setWebhooks(res.data);
|
||||
} catch (e) { console.error('Webhooks error:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadWebhookEvents = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/webhooks/events');
|
||||
if (res.code === 0) setWebhookEvents(res.data.events);
|
||||
} catch (e) { console.error('Events error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setDeliveries(res.data);
|
||||
} catch (e) { console.error('Deliveries error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadSdkPackages = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/sdk/packages');
|
||||
if (res.code === 0) setSdkPackages(res.data.packages);
|
||||
} catch (e) { console.error('SDK error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadApiDocs = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/api-docs');
|
||||
if (res.code === 0) setDocSections(res.data.sections);
|
||||
} catch (e) { console.error('Docs error:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
|
||||
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
|
||||
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
|
||||
|
||||
const handleCreateWebhook = async () => {
|
||||
try {
|
||||
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
|
||||
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
|
||||
setShowWebhookModal(false);
|
||||
setWebhookForm({ name: '', url: '', events: [] });
|
||||
loadWebhooks();
|
||||
if (res.data?.secret) {
|
||||
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
|
||||
}
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: string) => {
|
||||
if (!confirm('确定删除此 Webhook?')) return;
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
|
||||
loadWebhooks();
|
||||
setSelectedWebhook(null);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleToggleWebhook = async (wh: Webhook) => {
|
||||
const newStatus = wh.status === 'active' ? 'paused' : 'active';
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleTestWebhook = async (id: string) => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
|
||||
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
|
||||
});
|
||||
setTestResult(res.data);
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleViewDeliveries = (wh: Webhook) => {
|
||||
setSelectedWebhook(wh);
|
||||
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
loadDeliveries(wh.id);
|
||||
};
|
||||
|
||||
const fmtTime = (t: string | null) => {
|
||||
if (!t) return '-';
|
||||
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const cls = status === 'active' ? 'badge-active' : status === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
|
||||
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||
};
|
||||
|
||||
const methodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
|
||||
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Copied to clipboard');
|
||||
}).catch(() => alert('Copy failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-developer">
|
||||
<div className="dev-header">
|
||||
<h2>开发者工具</h2>
|
||||
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="dev-tabs">
|
||||
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
|
||||
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
|
||||
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="dev-tab-content">
|
||||
{loading && <div className="dev-loading">加载中...</div>}
|
||||
|
||||
{/* ===== Webhooks Tab ===== */}
|
||||
{activeTab === 'webhooks' && !loading && (
|
||||
<div>
|
||||
{selectedWebhook ? (
|
||||
/* Webhook Detail / Deliveries View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
|
||||
← Back to list
|
||||
</button>
|
||||
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
|
||||
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
|
||||
<strong>Test Result: </strong>
|
||||
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
|
||||
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
|
||||
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
|
||||
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveries.items.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td className="mono-text">{d.event}</td>
|
||||
<td>{statusBadge(d.status)}</td>
|
||||
<td>{d.response_code || '-'}</td>
|
||||
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
|
||||
<td>{d.attempts}</td>
|
||||
<td>{fmtTime(d.created_at)}</td>
|
||||
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{deliveries.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
|
||||
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
|
||||
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Webhook List View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webhooks.items.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
|
||||
<td>
|
||||
{w.events.length > 0 ? (
|
||||
<span>{w.events.length} events</span>
|
||||
) : <span className="dev-muted">All events</span>}
|
||||
</td>
|
||||
<td>{statusBadge(w.status)}</td>
|
||||
<td>{w.total_deliveries}</td>
|
||||
<td>
|
||||
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
|
||||
{w.success_rate}%
|
||||
</span>
|
||||
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
|
||||
</td>
|
||||
<td>{fmtTime(w.last_triggered_at)}</td>
|
||||
<td className="dev-actions">
|
||||
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||||
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{webhooks.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
|
||||
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
|
||||
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支持的事件 Info */}
|
||||
{webhookEvents.length > 0 && (
|
||||
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
|
||||
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
|
||||
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Webhook Modal */}
|
||||
{showWebhookModal && (
|
||||
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
|
||||
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>新建 Webhook</h3>
|
||||
<div className="dev-form-group">
|
||||
<label>名称</label>
|
||||
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>URL</label>
|
||||
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>Events (leave empty for all)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
|
||||
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
|
||||
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
|
||||
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
|
||||
}} />
|
||||
{ev.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-modal-actions">
|
||||
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
|
||||
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== SDK Download Center Tab ===== */}
|
||||
{activeTab === 'sdk' && !loading && (
|
||||
<div>
|
||||
{/* Language Selector */}
|
||||
<div className="dev-toolbar">
|
||||
{sdkPackages.map(pkg => (
|
||||
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
|
||||
{pkg.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
|
||||
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
|
||||
{/* Package Info */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
|
||||
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
|
||||
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install Command */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
{pkg.install_cmd}
|
||||
</code>
|
||||
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能特性 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{pkg.features.map(f => (
|
||||
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Example */}
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
|
||||
<code>{pkg.code_example}</code>
|
||||
</pre>
|
||||
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Interactive API Docs Tab ===== */}
|
||||
{activeTab === 'docs' && !loading && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
|
||||
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
|
||||
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
|
||||
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
|
||||
</div>
|
||||
|
||||
{docSections.map(section => {
|
||||
const isExpanded = expandedSection === section.section;
|
||||
return (
|
||||
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
|
||||
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '18px' }}>
|
||||
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
|
||||
</span>
|
||||
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
|
||||
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
|
||||
</div>
|
||||
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
{isExpanded && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
|
||||
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
|
||||
{section.endpoints.map(ep => {
|
||||
const epKey = `${section.section}-${ep.path}`;
|
||||
const isEpExpanded = expandedEndpoint === epKey;
|
||||
return (
|
||||
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
|
||||
<div
|
||||
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
|
||||
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
{methodBadge(ep.method)}
|
||||
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
|
||||
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{isEpExpanded && (
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
|
||||
|
||||
{/* 参数 */}
|
||||
{ep.params.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
|
||||
<table className="dev-table" style={{ fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.params.map(p => (
|
||||
<tr key={p.name}>
|
||||
<td className="mono-text">{p.name}</td>
|
||||
<td>{p.type}</td>
|
||||
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
|
||||
<td>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 请求示例 */}
|
||||
{ep.example_request && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_request}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 响应示例 */}
|
||||
{ep.example_response && (
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_response}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
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
@@ -1,538 +0,0 @@
|
||||
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;
|
||||
+342
-963
File diff suppressed because it is too large.
Load diff
@@ -1,405 +0,0 @@
|
||||
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>
|
||||
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,729 +0,0 @@
|
||||
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;
|
||||
File diff suppressed because it is too large.
Load diff
+111
-355
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
||||
|
||||
@@ -15,27 +15,36 @@ const formatNumber = (n: number) => {
|
||||
};
|
||||
|
||||
const capabilityColors: Record<string, string> = {
|
||||
text: '#5B5FED', thinking: '#8B5CF6', vision: '#219B5E', tool: '#3B82F6',
|
||||
image: '#D9383A', audio: '#E8922C', asr: '#0EA5E9', ocr: '#F59E0B',
|
||||
embedding: '#10B981', rerank: '#EC4899', video: '#EF4444',
|
||||
text: '#5B5FED',
|
||||
vision: '#219B5E',
|
||||
audio: '#E8922C',
|
||||
video: '#D9383A',
|
||||
image: '#D9383A',
|
||||
thinking: '#8B5CF6',
|
||||
tool: '#3B82F6',
|
||||
};
|
||||
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
text: '文本对话', thinking: '推理思考', vision: '视觉理解', tool: '工具调用',
|
||||
image: '图像生成', audio: '语音合成', asr: '语音识别', ocr: 'OCR识别',
|
||||
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
|
||||
text: '文本',
|
||||
vision: '视觉',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
image: '图像',
|
||||
thinking: '思考',
|
||||
tool: '工具',
|
||||
};
|
||||
|
||||
// Test result type
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
// Model editing type
|
||||
interface EditingModel {
|
||||
id?: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
provider_id: string;
|
||||
api_key: string;
|
||||
endpoint: string;
|
||||
@@ -46,42 +55,6 @@ interface EditingModel {
|
||||
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[]>([]);
|
||||
@@ -95,19 +68,15 @@ const Models: React.FC = () => {
|
||||
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);
|
||||
|
||||
// Model test state
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
// Model editor state
|
||||
const [showModelEditor, setShowModelEditor] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
|
||||
const [modelSaving, setModelSaving] = useState(false);
|
||||
// 按能力默认模型
|
||||
const [capDefaults, setCapDefaults] = useState<Record<string, string>>({});
|
||||
const [capList, setCapList] = useState<any[]>([]);
|
||||
const [capModels, setCapModels] = useState<any[]>([]);
|
||||
const [savingCap, setSavingCap] = useState<string | null>(null);
|
||||
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '',
|
||||
@@ -116,11 +85,10 @@ const Models: React.FC = () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [provRes, modelsRes, protoRes, capRes] = await Promise.all([
|
||||
const [provRes, modelsRes, protoRes] = await Promise.all([
|
||||
adminAPI.getProviders(),
|
||||
adminAPI.getAdminModels({ page_size: 100 }),
|
||||
adminAPI.getProtocols(),
|
||||
adminAPI.getCapabilityDefaults().catch(() => null),
|
||||
]);
|
||||
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
|
||||
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
|
||||
@@ -129,7 +97,6 @@ const Models: React.FC = () => {
|
||||
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,
|
||||
@@ -138,12 +105,6 @@ const Models: React.FC = () => {
|
||||
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
|
||||
}));
|
||||
setModels(mappedModels);
|
||||
if (capRes) {
|
||||
const cd = capRes.data || capRes;
|
||||
setCapList(cd.capabilities || []);
|
||||
setCapModels(cd.models || []);
|
||||
setCapDefaults(cd.defaults || {});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
|
||||
} finally {
|
||||
@@ -151,104 +112,23 @@ const Models: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置某个能力的默认模型
|
||||
const handleSetCapDefault = async (capability: string, modelId: string) => {
|
||||
try {
|
||||
setSavingCap(capability);
|
||||
await adminAPI.setCapabilityDefault(capability, modelId || null);
|
||||
setCapDefaults((prev) => {
|
||||
const next = { ...prev };
|
||||
if (modelId) next[capability] = modelId;
|
||||
else delete next[capability];
|
||||
return next;
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError('保存失败:' + (err.message || err.detail || JSON.stringify(err)));
|
||||
} finally {
|
||||
setSavingCap(null);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
// Test model connection
|
||||
const handleTestConnection = async (modelId: string) => {
|
||||
setTestingIds(prev => new Set(prev).add(modelId));
|
||||
setTestingModelId(modelId);
|
||||
setTestResult(null);
|
||||
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 }));
|
||||
setTestResult(result.success !== undefined ? result : (result.data || result as TestResult));
|
||||
} catch (err: any) {
|
||||
setTestResults(prev => ({ ...prev, [modelId]: { success: false, message: err.message || '测试失败' } }));
|
||||
setTestResult({ success: false, message: err.message || '测试失败' });
|
||||
} finally {
|
||||
setTestingIds(prev => { const next = new Set(prev); next.delete(modelId); return next; });
|
||||
setTestingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 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('video')) return 'video';
|
||||
if (lower.includes('audio')) return 'audio';
|
||||
if (lower.includes('asr') || lower.includes('speech')) return 'asr';
|
||||
if (lower.includes('ocr')) return 'ocr';
|
||||
if (lower.includes('embedding')) return 'embedding';
|
||||
if (lower.includes('rerank')) return 'rerank';
|
||||
if (lower.includes('vision')) return 'vision';
|
||||
if (lower.includes('thinking') || lower.includes('tool')) return 'thinking';
|
||||
return 'text';
|
||||
};
|
||||
|
||||
const modelTypeLabels: Record<string, { label: string; color: string }> = {
|
||||
text: { label: '文本', color: '#5B5FED' },
|
||||
thinking: { label: '推理/工具', color: '#8B5CF6' },
|
||||
image: { label: '图像生成', color: '#D9383A' },
|
||||
audio: { label: '语音合成', color: '#E8922C' },
|
||||
asr: { label: '语音识别', color: '#0EA5E9' },
|
||||
ocr: { label: 'OCR识别', color: '#F59E0B' },
|
||||
embedding: { label: '嵌入向量', color: '#10B981' },
|
||||
rerank: { label: '重排序', color: '#EC4899' },
|
||||
video: { label: '视频生成', color: '#EF4444' },
|
||||
vision: { label: '视觉理解', color: '#219B5E' },
|
||||
};
|
||||
|
||||
// Set default model
|
||||
const handleSetDefault = async (modelId: string) => {
|
||||
try {
|
||||
await adminAPI.setDefaultModel(modelId);
|
||||
@@ -258,14 +138,17 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Provider operations
|
||||
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,
|
||||
name: providerForm.name,
|
||||
protocol: providerForm.protocol,
|
||||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
||||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
||||
});
|
||||
setShowAddProvider(false);
|
||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||
@@ -282,7 +165,8 @@ const Models: React.FC = () => {
|
||||
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,
|
||||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
||||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
||||
});
|
||||
setEditingProvider(null);
|
||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||
@@ -293,7 +177,7 @@ const Models: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
if (!confirm('确定删除该供应商?关联的模型也会受影响。')) return;
|
||||
if (!confirm('确定删除该供应商?')) return;
|
||||
try {
|
||||
await adminAPI.deleteProvider(id);
|
||||
await loadData();
|
||||
@@ -302,6 +186,7 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch models from provider API
|
||||
const handleFetchModels = async (providerId: string) => {
|
||||
try {
|
||||
const result = await adminAPI.fetchProviderModels(providerId) as any;
|
||||
@@ -315,6 +200,7 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Add selected models
|
||||
const handleAddModels = async (providerId: string) => {
|
||||
if (selectedModelIds.size === 0) return;
|
||||
try {
|
||||
@@ -328,17 +214,19 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle model selection
|
||||
const toggleModelSelection = (id: string) => {
|
||||
const next = new Set(selectedModelIds);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSelectedModelIds(next);
|
||||
const newSet = new Set(selectedModelIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setSelectedModelIds(newSet);
|
||||
};
|
||||
|
||||
// Open model editor
|
||||
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 || '',
|
||||
@@ -350,7 +238,7 @@ const Models: React.FC = () => {
|
||||
});
|
||||
} else {
|
||||
setEditingModel({
|
||||
name: '', display_name: '', provider_id: providerId || '',
|
||||
name: '', provider_id: providerId || '',
|
||||
api_key: '', endpoint: '',
|
||||
capabilities: ['text'], timeout: 60,
|
||||
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
|
||||
@@ -359,6 +247,7 @@ const Models: React.FC = () => {
|
||||
setShowModelEditor(true);
|
||||
};
|
||||
|
||||
// Save model
|
||||
const handleSaveModel = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingModel || !editingModel.name.trim()) return;
|
||||
@@ -378,6 +267,7 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete model
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
if (!confirm('确定删除该模型?')) return;
|
||||
try {
|
||||
@@ -388,103 +278,50 @@ const Models: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle capability
|
||||
const toggleCapability = (cap: string) => {
|
||||
if (!editingModel) return;
|
||||
const caps = editingModel.capabilities || [];
|
||||
editingModel.capabilities = caps.includes(cap) ? caps.filter(c => c !== cap) : [...caps, cap];
|
||||
if (caps.includes(cap)) {
|
||||
editingModel.capabilities = caps.filter(c => c !== cap);
|
||||
} else {
|
||||
editingModel.capabilities = [...caps, cap];
|
||||
}
|
||||
setEditingModel({ ...editingModel });
|
||||
};
|
||||
|
||||
// Group models by provider
|
||||
const getProviderModels = (providerId: string) =>
|
||||
filteredModels.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
||||
models.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
||||
|
||||
const getProviderName = (providerId: string) => {
|
||||
const p = providers.find(p => p.id === providerId);
|
||||
return p ? p.name : providerId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#5A6480', fontSize: '13px' }}>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', 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: '' }); }}
|
||||
<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} />
|
||||
|
||||
{/* 按能力指定默认模型 */}
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)', padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 18 }}>🎯</span>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>按能力指定默认模型</h3>
|
||||
</div>
|
||||
<p style={{ margin: '4px 0 16px', color: '#6B7280', fontSize: 13 }}>
|
||||
Auto 模式下,技能 / 应用 / IM Bot 会根据所需能力自动匹配下方指定的默认模型;文本、推理、视觉、工具、OCR 等文本类能力若未单独指定,将回退到全局默认模型。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{capList.map((cap) => (
|
||||
<div key={cap.id} style={{ border: '1px solid #E5E7EB', borderRadius: 8, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: cap.color }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{cap.label}</span>
|
||||
<span style={{ fontSize: 11, color: '#9CA3AF' }}>{cap.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#9CA3AF', minHeight: 16 }}>{cap.desc}</div>
|
||||
<select
|
||||
value={capDefaults[cap.id] || ''}
|
||||
disabled={savingCap === cap.id}
|
||||
onChange={(e) => handleSetCapDefault(cap.id, e.target.value)}
|
||||
style={{ padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' }}
|
||||
>
|
||||
<option value=''>(未指定 · 文本类回退全局默认)</option>
|
||||
{capModels
|
||||
.filter((m) => (m.capabilities || []).includes(cap.id))
|
||||
.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.display_name || m.name}({m.provider_name})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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={{ textAlign: 'center', padding: '60px', color: '#999' }}>加载中...</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{providers.map(provider => {
|
||||
@@ -492,7 +329,8 @@ const Models: React.FC = () => {
|
||||
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 key={provider.id} style={{ background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
{/* Provider Header */}
|
||||
<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' }}>
|
||||
@@ -503,9 +341,8 @@ const Models: React.FC = () => {
|
||||
{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' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
|
||||
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
|
||||
</div>
|
||||
@@ -520,6 +357,7 @@ const Models: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Fetch Panel */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
@@ -534,8 +372,7 @@ const Models: React.FC = () => {
|
||||
</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' }}>
|
||||
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer', padding: '4px 8px', background: selectedModelIds.has(m.id) ? '#EDE9FE' : '#fff', border: '1px solid #D1D5DB', borderRadius: '4px' }}>
|
||||
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
|
||||
{m.name || m.id}
|
||||
</label>
|
||||
@@ -544,19 +381,18 @@ const Models: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Models List */}
|
||||
<div style={{ padding: '0' }}>
|
||||
{provModels.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#6B7280', fontSize: '13px' }}>
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#999', 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>
|
||||
<th style={thStyle}>调用量</th>
|
||||
@@ -568,23 +404,7 @@ const Models: React.FC = () => {
|
||||
<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>
|
||||
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A' }}>⭐ 默认模型</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
@@ -601,95 +421,38 @@ const Models: React.FC = () => {
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ fontSize: '12px', color: '#5A6480' }}>
|
||||
¥{(model.pricePerMillionTokens || 0).toFixed(4)}/M
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
¥{model.pricePerMillionTokens?.toFixed(4) || '0.0000'}/M
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: '#374151' }}>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
{formatNumber(model.totalCalls || 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#9CA3AF' }}>
|
||||
{formatNumber(model.totalTokens || 0)} tokens
|
||||
</div>
|
||||
</span>
|
||||
</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) ? '测试中...' : '测试'}
|
||||
{/* Test Connection Button */}
|
||||
<button onClick={() => handleTestConnection(model.id)} disabled={testingModelId === model.id}
|
||||
style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
|
||||
{testingModelId === 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>
|
||||
{/* Test Result Popover */}
|
||||
{testResult && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}>
|
||||
{testResult.success ? '✓ 成功' : '✗ 失败'}
|
||||
{testResult.latency_ms && ` (${testResult.latency_ms}ms)`}
|
||||
</span>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10, background: '#fff', border: '1px solid #E5E7EB', borderRadius: '6px', padding: '8px 12px', fontSize: '12px', color: '#333', minWidth: '200px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', marginTop: '4px' }}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
<button onClick={() => setTestResult(null)} style={{ marginLeft: '8px', fontSize: '11px', color: '#999', background: 'none', border: 'none', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
@@ -705,9 +468,9 @@ const Models: React.FC = () => {
|
||||
})}
|
||||
|
||||
{providers.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px', background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ textAlign: 'center', padding: '60px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
|
||||
<p style={{ color: '#5A6480', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
||||
<p style={{ color: '#666', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -715,8 +478,8 @@ const Models: React.FC = () => {
|
||||
|
||||
{/* Provider Modal */}
|
||||
{showAddProvider && (
|
||||
<div style={modalOverlay} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
|
||||
<div style={modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div style={modalOverlay}>
|
||||
<div style={modalContent}>
|
||||
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
|
||||
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
||||
<div style={formGroup}>
|
||||
@@ -748,8 +511,8 @@ const Models: React.FC = () => {
|
||||
|
||||
{/* Model Editor Modal */}
|
||||
{showModelEditor && editingModel && (
|
||||
<div style={modalOverlay} onClick={() => setShowModelEditor(false)}>
|
||||
<div style={{ ...modalContent, maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={modalOverlay}>
|
||||
<div style={{ ...modalContent, maxWidth: '600px' }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
|
||||
<form onSubmit={handleSaveModel}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
@@ -758,16 +521,12 @@ const Models: React.FC = () => {
|
||||
<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} />
|
||||
<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>
|
||||
<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} />
|
||||
@@ -780,10 +539,7 @@ const Models: React.FC = () => {
|
||||
<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' }}>
|
||||
<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>
|
||||
@@ -829,16 +585,16 @@ const Models: React.FC = () => {
|
||||
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,
|
||||
fontWeight: 500, transition: 'opacity 0.2s',
|
||||
});
|
||||
|
||||
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 inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', 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' };
|
||||
const modalContent: React.CSSProperties = { background: '#fff', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
|
||||
|
||||
export default Models;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,520 +0,0 @@
|
||||
// @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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,665 +0,0 @@
|
||||
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>
|
||||
</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
@@ -1,235 +0,0 @@
|
||||
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;
|
||||
@@ -1,757 +0,0 @@
|
||||
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: "#e0e0e0", minHeight: "100vh" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", color: "#fff" }}>Agent 可视化编排</h2>
|
||||
<p style={{ margin: "4px 0 0", color: "#888", 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: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workflow list */}
|
||||
<div style={{ background: "#1a1a2e", borderRadius: "12px", overflow: "hidden" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid #333" }}>
|
||||
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
|
||||
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#888", fontWeight: 500 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>加载中...</td></tr>
|
||||
) : workflows.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>暂无工作流,点击"新建工作流"开始</td></tr>
|
||||
) : workflows.map(wf => (
|
||||
<tr key={wf.id} style={{ borderBottom: "1px solid #222" }}>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<div style={{ color: "#fff", fontWeight: 500 }}>{wf.name}</div>
|
||||
<div style={{ color: "#666", 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: "#aaa" }}>{wf.nodes?.length || 0}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#aaa" }}>v{wf.version}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#888", 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: "#0d0d1a", color: "#e0e0e0" }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#16162a", borderBottom: "1px solid #2a2a4a", 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 #333", borderRadius: "6px", padding: "4px 12px", color: "#fff", fontSize: "14px", width: "200px" }}
|
||||
/>
|
||||
<span style={{ color: "#666", 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: "#1a1a2e", borderBottom: "1px solid #2a2a4a" }}>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#888" }}>输入文本</label>
|
||||
<textarea
|
||||
value={executeInput}
|
||||
onChange={e => setExecuteInput(e.target.value)}
|
||||
placeholder="输入测试文本..."
|
||||
style={{ width: "100%", background: "#0d0d1a", border: "1px solid #333", borderRadius: "6px", padding: "8px", color: "#fff", 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: "#888", 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: "#aaa" }}>{l.node_label}</span>
|
||||
{l.output_preview && <span style={{ color: "#666" }}> → {l.output_preview.slice(0, 100)}</span>}
|
||||
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: "8px", padding: "8px", background: "#0d0d1a", borderRadius: "6px", border: "1px solid #333" }}>
|
||||
<div style={{ fontSize: "12px", color: "#888", 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: "#16162a", borderRight: "1px solid #2a2a4a", padding: "12px", overflowY: "auto" }}>
|
||||
<div style={{ fontSize: "12px", color: "#666", 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: "#1a1a2e",
|
||||
border: "1px solid #333",
|
||||
borderLeft: `3px solid ${NODE_COLORS[type]}`,
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "#222244")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "#1a1a2e")}
|
||||
>
|
||||
<span style={{ color: "#fff", fontSize: "13px" }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundImage: "radial-gradient(circle, #1a1a2e 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
background: "#0d0d1a",
|
||||
}}
|
||||
>
|
||||
{/* 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="#1a1a2e" 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: "#1a1a2e",
|
||||
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: "#aaa" }}>{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 #1a1a2e",
|
||||
}}
|
||||
/>
|
||||
<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: "#444", cursor: "pointer", border: "2px solid #1a1a2e",
|
||||
}}
|
||||
/>
|
||||
</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: "#16162a", borderLeft: "1px solid #2a2a4a", padding: "16px", overflowY: "auto" }}>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600, color: "#fff", marginBottom: "4px" }}>
|
||||
{NODE_LABELS[selectedNode.type] || selectedNode.type}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#666", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
|
||||
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ fontSize: "12px", color: "#888" }}>显示名称</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: "#888" }}>{schema.label || key}</label>
|
||||
{schema.help && <div style={{ fontSize: "11px", color: "#666", 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: "#666", 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: "#aaa", border: "1px solid #333", borderRadius: "4px",
|
||||
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
|
||||
};
|
||||
|
||||
const statCard: React.CSSProperties = {
|
||||
background: "#1a1a2e", borderRadius: "8px", padding: "16px",
|
||||
};
|
||||
|
||||
const configInput: React.CSSProperties = {
|
||||
width: "100%", background: "#0d0d1a", border: "1px solid #333",
|
||||
borderRadius: "4px", padding: "6px 8px", color: "#fff", 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",
|
||||
};
|
||||
}
|
||||
+1047
-1137
File diff suppressed because it is too large.
Load diff
+1080
-1163
File diff suppressed because it is too large.
Load diff
@@ -407,7 +407,7 @@ const TimelinePanel: React.FC<{
|
||||
pdf.text('我的AI进化时间线', 10, 15);
|
||||
pdf.setFontSize(10);
|
||||
pdf.text(`生成日期: ${dateStr}`, 10, 22);
|
||||
pdf.text('七虎AI', pageWidth - 10, 22, { align: 'right' });
|
||||
pdf.text('Qihu-AIFlow', pageWidth - 10, 22, { align: 'right' });
|
||||
|
||||
// 单页添加图片,自动缩放适应
|
||||
const maxHeight = pageHeight - 35;
|
||||
@@ -417,7 +417,7 @@ const TimelinePanel: React.FC<{
|
||||
// 底部水印
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(200, 200, 200);
|
||||
pdf.text('七虎AI', pageWidth / 2, pageHeight - 5, { align: 'center' });
|
||||
pdf.text('Qihu-AIFlow', pageWidth / 2, pageHeight - 5, { align: 'center' });
|
||||
|
||||
pdf.save(`${filename}.pdf`);
|
||||
setExportMsg(`时间线已导出为 PDF: ${filename}.pdf`);
|
||||
@@ -746,7 +746,7 @@ const ProfilePanel: React.FC<{ profile: EvolutionProfile; skills: EvolutionSkill
|
||||
{/* Footer */}
|
||||
<div className="evo-share-card-footer">
|
||||
<span style={{ opacity: 0.5, fontSize: '11px' }}>{new Date().toLocaleDateString()}</span>
|
||||
<span style={{ opacity: 0.5, fontSize: '11px' }}>七虎AI</span>
|
||||
<span style={{ opacity: 0.5, fontSize: '11px' }}>Qihu-AIFlow</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,486 +1,370 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mockAdBanners, mockApps } from '@/mock/data';
|
||||
import type { AdBanner, MarketApp } from '@/types';
|
||||
import './LandingPage.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 typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
||||
const [skillData, setSkillData] = useState<{skills: any[], agents: any[], mcps: any[]}>({skills: [], agents: [], mcps: []});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const apiModule = await import('@/api');
|
||||
const res = await apiModule.adminAPI.getAdminSkills({page: 1, page_size: 50, status: 'published'});
|
||||
const allApps: any[] = (res as any)?.data?.items || (res as any)?.data || [];
|
||||
const sortFn = (a: any, b: any) => (b.usage_count || b.install_count || 0) - (a.usage_count || a.install_count || 0);
|
||||
const skills = allApps.filter(a => a.type === 'skill').sort(sortFn).slice(0, 5);
|
||||
const agents = allApps.filter(a => a.type === 'agent').sort(sortFn).slice(0, 5);
|
||||
const mcps = allApps.filter(a => a.type === 'mcp').sort(sortFn).slice(0, 5);
|
||||
if (skills.length + agents.length + mcps.length === 0) {
|
||||
setSkillData({skills: publishedApps.filter(a => a.type === 'skill').slice(0, 5), agents: publishedApps.filter(a => a.type === 'agent').slice(0, 5), mcps: publishedApps.filter(a => a.type === 'mcp').slice(0, 5)});
|
||||
} else { setSkillData({skills, agents, mcps}); }
|
||||
} catch (e) {
|
||||
setSkillData({skills: publishedApps.filter(a => a.type === 'skill').slice(0, 5), agents: publishedApps.filter(a => a.type === 'agent').slice(0, 5), mcps: publishedApps.filter(a => a.type === 'mcp').slice(0, 5)});
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
const [toastMsg, setToastMsg] = useState('');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
const filteredApps = appTypeFilter
|
||||
? publishedApps.filter(a => a.type === appTypeFilter)
|
||||
: publishedApps;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 60);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToastMsg(msg);
|
||||
setTimeout(() => setToastMsg(''), 2000);
|
||||
};
|
||||
|
||||
const handleAppClick = (appName: string) => {
|
||||
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
|
||||
setTimeout(() => navigate('/login'), 800);
|
||||
};
|
||||
|
||||
const nextSlide = useCallback(() => {
|
||||
if (banners.length === 0) return;
|
||||
setCurrentSlide(prev => (prev + 1) % banners.length);
|
||||
}, [banners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1 || isPaused) return;
|
||||
const timer = setInterval(nextSlide, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [banners.length, isPaused, nextSlide]);
|
||||
|
||||
const goToSlide = (idx: number) => setCurrentSlide(idx);
|
||||
|
||||
const getGradient = (app: MarketApp): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
||||
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
};
|
||||
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
|
||||
};
|
||||
|
||||
// Platform Architecture - layers from bottom to top
|
||||
const archLayers = [
|
||||
{
|
||||
label: '应用层',
|
||||
color: '#6C63FF',
|
||||
items: ['AI 对话工作台', '知识库问答', '自动化流程', 'IM Bot 接入', 'API 集成'],
|
||||
},
|
||||
{
|
||||
label: '能力层',
|
||||
color: '#10B981',
|
||||
items: ['技能市场 (Skill)', '智能体 (Agent)', 'MCP 协议', 'RAG 检索增强', '工具链编排'],
|
||||
},
|
||||
{
|
||||
label: '模型层',
|
||||
color: '#F59E0B',
|
||||
items: ['DeepSeek', 'GPT-4o', 'Claude', 'Qwen', '多模态模型'],
|
||||
},
|
||||
{
|
||||
label: '基础设施',
|
||||
color: '#3B82F6',
|
||||
items: ['向量数据库', '对象存储', '任务调度', 'API 网关', '监控告警'],
|
||||
},
|
||||
];
|
||||
|
||||
// Capability groups
|
||||
const capabilities = [
|
||||
{
|
||||
icon: <SVGIcon size={28}><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>,
|
||||
title: 'AI 对话',
|
||||
desc: '接入 50+ 主流大模型,支持流式输出、多轮对话、Function Calling,你的专属 AI 助手',
|
||||
},
|
||||
{
|
||||
icon: <SVGIcon size={28}><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>,
|
||||
title: '知识库',
|
||||
desc: '上传文档、网页、代码构建个人知识库,RAG 增强检索让 AI 回答更精准可靠',
|
||||
},
|
||||
{
|
||||
icon: <SVGIcon size={28}><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>,
|
||||
title: '技能市场',
|
||||
desc: '100+ 预制 AI 技能,一键安装即用。支持 Skill、Agent、MCP 三种形态,持续扩展',
|
||||
},
|
||||
{
|
||||
icon: <SVGIcon size={28}><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>,
|
||||
title: 'IM Bot',
|
||||
desc: '将 AI 能力接入飞书、微信、钉钉,在聊天中调用 AI,无缝融入日常工作流',
|
||||
},
|
||||
{
|
||||
icon: <SVGIcon size={28}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>,
|
||||
title: 'AI 自进化',
|
||||
desc: '从每次对话中持续学习,自动沉淀知识图谱,你的 AI 越用越懂你',
|
||||
},
|
||||
{
|
||||
icon: <SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>,
|
||||
title: 'Token 驾驶舱',
|
||||
desc: '实时监控调用量、Token 消耗与成本,精细化管理你的 AI 使用预算',
|
||||
},
|
||||
];
|
||||
|
||||
// Scenarios - individual practitioner focused
|
||||
const scenarios = [
|
||||
{ icon: '📚', title: '学习助手', desc: '构建课程知识库,AI 辅助理解复杂概念、整理笔记、解答疑问' },
|
||||
{ icon: '💼', title: '工作效率', desc: '自动化重复任务、智能撰写文档邮件、快速检索信息,效率翻倍' },
|
||||
{ icon: '🤖', title: 'AI Agent 开发', desc: '用 Skill + Agent + MCP 搭建专属 AI 助手,从原型到生产一站完成' },
|
||||
{ icon: '📊', title: '数据分析', desc: '自然语言查询数据库,自动生成可视化报告,让数据说话更简单' },
|
||||
{ icon: '✍️', title: '内容创作', desc: 'AI 辅助写文章、做翻译、润色文案,让创作更有灵感' },
|
||||
{ icon: '🔬', title: '技术探索', desc: '接入最新模型做实验,对比不同 LLM 效果,探索 AI 的前沿能力' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="landing-page-v2">
|
||||
{/* Toast */}
|
||||
{toastMsg && <div className="landing-toast-v2">{toastMsg}</div>}
|
||||
|
||||
{/* Header */}
|
||||
<header className={`landing-header-v2 ${scrolled ? 'scrolled' : ''}`}>
|
||||
<div className="landing-header-inner-v2">
|
||||
<div className="landing-brand-v2">
|
||||
<svg width="36" height="36" viewBox="0 0 32 32" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#5B5FED"/>
|
||||
<stop offset="100%" stopColor="#A78BFA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#logoGrad)"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
<span className="landing-brand-text">七虎AI</span>
|
||||
</div>
|
||||
<nav className="landing-nav-v2" style={{ cursor: 'pointer' }}>
|
||||
<a onClick={() => document.getElementById('what-is')?.scrollIntoView({ behavior: 'smooth' })}>平台介绍</a>
|
||||
<a onClick={() => document.getElementById('architecture')?.scrollIntoView({ behavior: 'smooth' })}>平台架构</a>
|
||||
<a onClick={() => document.getElementById('capabilities')?.scrollIntoView({ behavior: 'smooth' })}>能力体系</a>
|
||||
<a onClick={() => document.getElementById('scenarios')?.scrollIntoView({ behavior: 'smooth' })}>应用场景</a>
|
||||
<a onClick={() => document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' })}>技能生态</a>
|
||||
</nav>
|
||||
<div className="landing-actions-v2">
|
||||
<button className="btn-ghost-v2" onClick={() => navigate('/login')}>登录</button>
|
||||
<button className="btn-primary-v2" onClick={() => navigate('/login')}>免费注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section — What is 七虎AI */}
|
||||
<section className="hero-section-v2">
|
||||
<div className="hero-bg-orb orb-1" />
|
||||
<div className="hero-bg-orb orb-2" />
|
||||
<div className="hero-bg-orb orb-3" />
|
||||
<div className="hero-content-v2">
|
||||
<div className="hero-badge-v2">
|
||||
<span className="hero-badge-dot" />
|
||||
AI 个人工作台 · 为创造者而生
|
||||
</div>
|
||||
<h1 className="hero-title-v2">
|
||||
每一个 AI 创造者
|
||||
<span className="hero-gradient-text"> 的超级工作台</span>
|
||||
</h1>
|
||||
<p className="hero-subtitle-v2">
|
||||
七虎AI 是一站式 AI 能力集成平台,让你在一个地方完成对话、知识库、技能编排、模型切换、自动化搭建<br/>
|
||||
无需拼接多个工具,从想法到落地,一个平台就够了
|
||||
</p>
|
||||
<div className="hero-cta-v2">
|
||||
<button className="btn-hero-primary" onClick={() => navigate('/login')}>
|
||||
免费开始使用
|
||||
<SVGIcon size={18}><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></SVGIcon>
|
||||
</button>
|
||||
<button className="btn-hero-secondary" onClick={() => document.getElementById('what-is')?.scrollIntoView({ behavior: 'smooth' })}>
|
||||
了解平台
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Stats */}
|
||||
<div className="hero-stats-v2">
|
||||
<div className="hero-stat-item">
|
||||
<span className="hero-stat-num">100+</span>
|
||||
<span className="hero-stat-label">AI 技能</span>
|
||||
</div>
|
||||
<div className="hero-stat-divider" />
|
||||
<div className="hero-stat-item">
|
||||
<span className="hero-stat-num">50+</span>
|
||||
<span className="hero-stat-label">大模型接入</span>
|
||||
</div>
|
||||
<div className="hero-stat-divider" />
|
||||
<div className="hero-stat-item">
|
||||
<span className="hero-stat-num">6</span>
|
||||
<span className="hero-stat-label">IM 平台</span>
|
||||
</div>
|
||||
<div className="hero-stat-divider" />
|
||||
<div className="hero-stat-item">
|
||||
<span className="hero-stat-num">99.9%</span>
|
||||
<span className="hero-stat-label">可用性</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What is 七虎AI — Platform Introduction */}
|
||||
<section id="what-is" className="whatis-section-v2">
|
||||
<div className="section-header-v2">
|
||||
<span className="section-badge-v2">平台介绍</span>
|
||||
<h2>七虎AI 是什么?</h2>
|
||||
<p>一个面向 AI 爱好者和从业者的个人 AI 能力集成平台</p>
|
||||
</div>
|
||||
<div className="whatis-cards-v2">
|
||||
<div className="whatis-card">
|
||||
<div className="whatis-card-icon">🎯</div>
|
||||
<h3>能力聚合</h3>
|
||||
<p>将大模型对话、知识库、技能编排、自动化、IM Bot 等能力整合在一个平台,告别工具碎片化</p>
|
||||
</div>
|
||||
<div className="whatis-card">
|
||||
<div className="whatis-card-icon">⚡</div>
|
||||
<h3>开箱即用</h3>
|
||||
<p>预设 100+ AI 技能模板,50+ 大模型即配即用,无需从零搭建,注册即可上手</p>
|
||||
</div>
|
||||
<div className="whatis-card">
|
||||
<div className="whatis-card-icon">🔌</div>
|
||||
<h3>开放扩展</h3>
|
||||
<p>支持 MCP 协议、Skill 插件、Agent 编排、API 开放,你可以自由扩展和定制</p>
|
||||
</div>
|
||||
<div className="whatis-card">
|
||||
<div className="whatis-card-icon">🚀</div>
|
||||
<h3>持续进化</h3>
|
||||
<p>AI 自进化引擎从使用中持续学习,知识库自动更新,模型热切换,你的工作台越来越强</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Platform Architecture */}
|
||||
<section id="architecture" className="architecture-section-v2">
|
||||
<div className="section-header-v2">
|
||||
<span className="section-badge-v2">平台架构</span>
|
||||
<h2>四层架构 · 层层赋能</h2>
|
||||
<p>从基础设施到上层应用,理解 七虎AI 的技术全貌</p>
|
||||
</div>
|
||||
<div className="arch-layers-v2">
|
||||
{archLayers.map((layer, idx) => (
|
||||
<div key={idx} className="arch-layer" style={{ '--layer-color': layer.color } as React.CSSProperties}>
|
||||
<div className="arch-layer-header">
|
||||
<div className="arch-layer-dot" style={{ background: layer.color }} />
|
||||
<span className="arch-layer-label">{layer.label}</span>
|
||||
<div className="arch-layer-line" />
|
||||
</div>
|
||||
<div className="arch-layer-items">
|
||||
{layer.items.map((item, i) => (
|
||||
<span key={i} className="arch-layer-chip">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Downward arrows between layers */}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Banner Carousel */}
|
||||
{banners.length > 0 && (
|
||||
<section className="banner-section-v2">
|
||||
<div
|
||||
className="banner-carousel-v2"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<div className="banner-track-v2" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
||||
{banners.map((banner, idx) => (
|
||||
<div key={idx} className="banner-slide-v2">
|
||||
<div className="banner-content-v2">
|
||||
<span className="banner-tag">AI 精选</span>
|
||||
<h2>{banner.title}</h2>
|
||||
<p>{banner.title}</p>
|
||||
</div>
|
||||
<div className="banner-visual-v2">
|
||||
<div className="banner-shape" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="banner-dots-v2">
|
||||
{banners.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`banner-dot ${idx === currentSlide ? 'active' : ''}`}
|
||||
onClick={() => goToSlide(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Capabilities Section */}
|
||||
<section id="capabilities" className="capabilities-section-v2">
|
||||
<div className="section-header-v2">
|
||||
<span className="section-badge-v2">能力体系</span>
|
||||
<h2>六大核心能力</h2>
|
||||
<p>从对话到自动化,覆盖 AI 从业者的全部工作流</p>
|
||||
</div>
|
||||
<div className="capabilities-grid-v2">
|
||||
{capabilities.map((c, idx) => (
|
||||
<div key={idx} className="capability-card-v2">
|
||||
<div className="capability-icon-v2">{c.icon}</div>
|
||||
<h3>{c.title}</h3>
|
||||
<p>{c.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scenarios Section */}
|
||||
<section id="scenarios" className="scenarios-section-v2">
|
||||
<div className="section-header-v2">
|
||||
<span className="section-badge-v2">应用场景</span>
|
||||
<h2>每个 AI 创作者都用得上</h2>
|
||||
<p>无论你是学习者、开发者、还是内容创作者,都能找到属于你的打开方式</p>
|
||||
</div>
|
||||
<div className="scenarios-grid-v2">
|
||||
{scenarios.map((s, idx) => (
|
||||
<div key={idx} className="scenario-card-v2">
|
||||
<div className="scenario-emoji">{s.icon}</div>
|
||||
<h3>{s.title}</h3>
|
||||
<p>{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Market Preview */}
|
||||
<section id="market" className="market-section-v2">
|
||||
<div className="section-header-v2">
|
||||
<span className="section-badge-v2">技能生态</span>
|
||||
<h2>丰富的 AI 技能生态</h2>
|
||||
<p>Skills、Agent、MCP 工具一键安装,持续扩展你的 AI 能力边界</p>
|
||||
<div className="market-filter-v2">
|
||||
{(['', 'skill', 'agent', 'mcp'] as const).map(t => (
|
||||
<button
|
||||
key={t || 'all'}
|
||||
className={`filter-chip ${appTypeFilter === t ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter(t)}
|
||||
>
|
||||
{t === '' ? '全部' : typeLabels[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="market-grid-v2">
|
||||
{(() => {
|
||||
const hasRealData = skillData.skills.length + skillData.agents.length + skillData.mcps.length > 0;
|
||||
let displayApps: any[];
|
||||
if (appTypeFilter) {
|
||||
const key = appTypeFilter === 'skill' ? 'skills' : appTypeFilter === 'agent' ? 'agents' : 'mcps';
|
||||
displayApps = hasRealData ? skillData[key] : filteredApps.slice(0, 5);
|
||||
} else {
|
||||
displayApps = hasRealData ? [...skillData.skills, ...skillData.agents, ...skillData.mcps] : filteredApps.slice(0, 6);
|
||||
}
|
||||
return displayApps.map((app: any) => {
|
||||
const appType = app.type || 'skill';
|
||||
const appColor = typeColors[appType] || '#6C63FF';
|
||||
const appLabel = typeLabels[appType] || 'Skill';
|
||||
const appName = app.name || app.title || '未命名';
|
||||
const appDesc = app.description || app.desc || '';
|
||||
const appUsage = app.usage_count || app.install_count || 0;
|
||||
const appPrice = app.price || 0;
|
||||
return (
|
||||
<div key={app.id || appName} className="market-card-v2" onClick={() => handleAppClick(appName)}>
|
||||
<div className="market-card-icon-v2" style={{ background: hasRealData ? `linear-gradient(135deg, ${appColor}44, ${appColor}11)` : getGradient(app) }}>
|
||||
<span style={{ color: appColor, fontWeight: 700, fontSize: 16 }}>{appName.charAt(0)}</span>
|
||||
</div>
|
||||
<div className="market-card-body-v2">
|
||||
<div className="market-card-header-v2">
|
||||
<h4>{appName}</h4>
|
||||
<span className="type-badge" style={{ background: appColor, color: '#fff' }}>{appLabel}</span>
|
||||
</div>
|
||||
<p className="market-card-desc-v2">{appDesc}</p>
|
||||
<div className="market-card-meta-v2">
|
||||
{appUsage > 0 && <span>📥 {appUsage >= 1000 ? `${(appUsage/1000).toFixed(1)}k` : appUsage}次</span>}
|
||||
<span>{appPrice === 0 ? '免费' : `¥${appPrice}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="cta-section-v2">
|
||||
<div className="cta-bg-glow" />
|
||||
<h2>准备好开始你的 AI 之旅了吗?</h2>
|
||||
<p>免费注册,即刻拥有属于你自己的 AI 超级工作台</p>
|
||||
<button className="btn-hero-primary" onClick={() => navigate('/login')}>
|
||||
免费注册
|
||||
<SVGIcon size={18}><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></SVGIcon>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="landing-footer-v2">
|
||||
<div className="footer-inner-v2">
|
||||
<div className="footer-brand-v2">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
<span>七虎AI</span>
|
||||
</div>
|
||||
<div className="footer-links-v2">
|
||||
<div className="footer-col-v2">
|
||||
<h4>平台</h4>
|
||||
<a onClick={() => document.getElementById('what-is')?.scrollIntoView({ behavior: 'smooth' })}>平台介绍</a>
|
||||
<a onClick={() => document.getElementById('architecture')?.scrollIntoView({ behavior: 'smooth' })}>平台架构</a>
|
||||
<a onClick={() => document.getElementById('capabilities')?.scrollIntoView({ behavior: 'smooth' })}>能力体系</a>
|
||||
</div>
|
||||
<div className="footer-col-v2">
|
||||
<h4>资源</h4>
|
||||
<a href="#">API 文档</a>
|
||||
<a href="#">开发者指南</a>
|
||||
<a href="#">更新日志</a>
|
||||
</div>
|
||||
<div className="footer-col-v2">
|
||||
<h4>支持</h4>
|
||||
<a href="#">帮助中心</a>
|
||||
<a href="#">联系我们</a>
|
||||
<a href="#">社区论坛</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom-v2">
|
||||
<p>© 2026 七虎AI. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mockAdBanners, mockApps } from '@/mock/data';
|
||||
import type { AdBanner, MarketApp } from '@/types';
|
||||
import './LandingPage.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 typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
||||
const [toastMsg, setToastMsg] = useState('');
|
||||
|
||||
const filteredApps = appTypeFilter
|
||||
? publishedApps.filter(a => a.type === appTypeFilter)
|
||||
: publishedApps;
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToastMsg(msg);
|
||||
setTimeout(() => setToastMsg(''), 2000);
|
||||
};
|
||||
|
||||
const handleAppClick = (appName: string) => {
|
||||
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
|
||||
setTimeout(() => navigate('/login'), 800);
|
||||
};
|
||||
|
||||
// Auto rotate carousel
|
||||
const nextSlide = useCallback(() => {
|
||||
if (banners.length === 0) return;
|
||||
setCurrentSlide(prev => (prev + 1) % banners.length);
|
||||
}, [banners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1 || isPaused) return;
|
||||
const timer = setInterval(nextSlide, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [banners.length, isPaused, nextSlide]);
|
||||
|
||||
const goToSlide = (idx: number) => setCurrentSlide(idx);
|
||||
|
||||
const getGradient = (app: MarketApp): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
||||
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
};
|
||||
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
{/* Toast notification */}
|
||||
{toastMsg && (
|
||||
<div className="landing-toast">{toastMsg}</div>
|
||||
)}
|
||||
|
||||
{/* Hero / Nav bar */}
|
||||
<header className="landing-header">
|
||||
<div className="landing-header-inner">
|
||||
<div className="landing-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
<span className="brand-name">Qihu-AIFlow</span>
|
||||
</div>
|
||||
<nav className="landing-nav-links">
|
||||
<a href="#market">应用市场</a>
|
||||
<a href="#features">平台能力</a>
|
||||
</nav>
|
||||
<div className="landing-header-actions">
|
||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
|
||||
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="landing-hero">
|
||||
<div className="landing-hero-content">
|
||||
<h1 className="hero-title">
|
||||
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
|
||||
</h1>
|
||||
<p className="hero-desc">
|
||||
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
|
||||
让每个人都能轻松搭建属于自己的AI自动化SOP。
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
||||
免费开始使用
|
||||
</button>
|
||||
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
|
||||
浏览应用市场
|
||||
</button>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">3,500+</span>
|
||||
<span className="stat-label">活跃用户</span>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">120+</span>
|
||||
<span className="stat-label">应用/Skills</span>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">99.8%</span>
|
||||
<span className="stat-label">服务可用率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Carousel Section */}
|
||||
{banners.length > 0 && (
|
||||
<section className="landing-banners">
|
||||
<div className="landing-section-inner">
|
||||
<h2 className="section-title">推荐应用</h2>
|
||||
<div
|
||||
className="carousel-container"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
||||
{banners.map(banner => (
|
||||
<div key={banner.id} className="carousel-slide">
|
||||
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<div className="carousel-card-content">
|
||||
<div className="carousel-badge">推荐</div>
|
||||
<h3 className="carousel-title">{banner.title}</h3>
|
||||
{banner.linkAppName && (
|
||||
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
|
||||
)}
|
||||
<button
|
||||
className="carousel-cta"
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
查看详情
|
||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div className="carousel-card-visual">
|
||||
<div className="visual-circle c1"/>
|
||||
<div className="visual-circle c2"/>
|
||||
<div className="visual-circle c3"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Dots */}
|
||||
{banners.length > 1 && (
|
||||
<div className="carousel-dots">
|
||||
{banners.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`carousel-dot ${idx === currentSlide ? 'active' : ''}`}
|
||||
onClick={() => goToSlide(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Arrows */}
|
||||
{banners.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="carousel-arrow carousel-arrow-left"
|
||||
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
|
||||
>
|
||||
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
|
||||
</button>
|
||||
<button
|
||||
className="carousel-arrow carousel-arrow-right"
|
||||
onClick={() => nextSlide()}
|
||||
>
|
||||
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Market Preview Section */}
|
||||
<section className="landing-market" id="market">
|
||||
<div className="landing-section-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">应用市场</h2>
|
||||
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="market-filter-tabs">
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === '' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('')}
|
||||
>全部</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'skill' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('skill')}
|
||||
>Skills</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'agent' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('agent')}
|
||||
>Agent</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'mcp' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('mcp')}
|
||||
>MCP</button>
|
||||
</div>
|
||||
|
||||
{/* App Grid */}
|
||||
<div className="market-app-grid">
|
||||
{filteredApps.map(app => (
|
||||
<div key={app.id} className="market-app-card" onClick={() => handleAppClick(app.name)}>
|
||||
<div className="app-card-cover" style={{ background: getGradient(app) }}>
|
||||
<span className="app-card-type-badge" style={{ background: typeColors[app.type] }}>
|
||||
{typeLabels[app.type]}
|
||||
</span>
|
||||
{app.price > 0 && (
|
||||
<span className="app-card-price">{app.price} 元</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="app-card-body">
|
||||
<h4 className="app-card-name">{app.name}</h4>
|
||||
<p className="app-card-desc">{app.description}</p>
|
||||
<div className="app-card-meta">
|
||||
<span className="app-card-rating">
|
||||
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
|
||||
{app.ratingAvg > 0 ? app.ratingAvg : '-'}
|
||||
</span>
|
||||
<span className="app-card-installs">{app.installCount.toLocaleString()} 次安装</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="market-more">
|
||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
|
||||
登录查看更多应用
|
||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="landing-features" id="features">
|
||||
<div className="landing-section-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">平台核心能力</h2>
|
||||
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
|
||||
</div>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><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>
|
||||
</div>
|
||||
<h3 className="feature-title">多模型AI对话</h3>
|
||||
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><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>
|
||||
</div>
|
||||
<h3 className="feature-title">私有知识库</h3>
|
||||
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><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>
|
||||
</div>
|
||||
<h3 className="feature-title">应用市场</h3>
|
||||
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">Token驾驶舱</h3>
|
||||
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><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>
|
||||
</div>
|
||||
<h3 className="feature-title">IM频道绑定</h3>
|
||||
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><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>
|
||||
</div>
|
||||
<h3 className="feature-title">自动化任务编排</h3>
|
||||
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="landing-cta">
|
||||
<div className="landing-section-inner">
|
||||
<h2 className="cta-title">准备好开始了吗?</h2>
|
||||
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
|
||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
||||
免费注册,立即体验
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="landing-footer">
|
||||
<div className="landing-section-inner">
|
||||
<div className="footer-content">
|
||||
<div className="footer-brand">
|
||||
<div className="landing-brand">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
|
||||
AI智能自动化PaaS平台
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<div className="footer-col">
|
||||
<h4>产品</h4>
|
||||
<a>AI聊天</a>
|
||||
<a>知识库</a>
|
||||
<a>应用市场</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4>支持</h4>
|
||||
<a>帮助文档</a>
|
||||
<a>API参考</a>
|
||||
<a>联系我们</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>Qihu-AIFlow 2026. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -1,15 +1,14 @@
|
||||
/* ===== Login Page V3 - Dark Theme ===== */
|
||||
/* ===== Login Page ===== */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: #F5F7FC;
|
||||
background: var(--color-bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Background decorations */
|
||||
.login-bg-decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -20,37 +19,35 @@
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.bg-circle.c1 {
|
||||
width: 600px; height: 600px;
|
||||
background: #6C63FF;
|
||||
background: var(--color-accent);
|
||||
top: -200px; right: -100px;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.bg-circle.c2 {
|
||||
width: 400px; height: 400px;
|
||||
background: #06B6D4;
|
||||
background: #8B8DFF;
|
||||
bottom: -100px; left: -50px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.bg-circle.c3 {
|
||||
width: 300px; height: 300px;
|
||||
background: #A78BFA;
|
||||
background: var(--color-accent);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.06;
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(108, 99, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(108, 99, 255, 0.03) 1px, transparent 1px);
|
||||
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
@@ -60,211 +57,202 @@
|
||||
width: 900px;
|
||||
min-height: 560px;
|
||||
background: #FFFFFF;
|
||||
backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
overflow: hidden;
|
||||
z-index: var(--z-base);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: loginSlideUp 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes loginSlideUp {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Left brand panel */
|
||||
/* Brand Panel */
|
||||
.login-brand-panel {
|
||||
width: 400px;
|
||||
background: linear-gradient(135deg, rgba(108, 99, 255, 0.08) 0%, rgba(167, 139, 250, 0.05) 100%);
|
||||
padding: 48px 40px;
|
||||
background: linear-gradient(135deg, #2D2B55 0%, #1A1A2E 50%, #2D2B55 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-brand-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 300px; height: 300px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(91,95,237,0.3) 0%, transparent 70%);
|
||||
top: -50px; right: -50px;
|
||||
}
|
||||
|
||||
.login-brand-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 200px; height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(139,141,255,0.2) 0%, transparent 70%);
|
||||
bottom: -30px; left: -30px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 32px;
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #1A1F36;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 15px;
|
||||
color: #5A6480;
|
||||
margin-bottom: 32px;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: var(--space-md);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brand-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #5A6480;
|
||||
gap: var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.bf-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10B981;
|
||||
color: #6C63FF;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Form Panel */
|
||||
.login-form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right form panel */
|
||||
.login-form-panel {
|
||||
flex: 1;
|
||||
padding: 48px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-4xl);
|
||||
}
|
||||
|
||||
.login-form-inner {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 24px;
|
||||
gap: var(--space-2xl);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #5A6480;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-tertiary);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-tab.active {
|
||||
background: rgba(108, 99, 255, 0.15);
|
||||
color: #8B85FF;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-tab:hover:not(.active) {
|
||||
color: #5A6480;
|
||||
.login-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.form-error {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: #F87171;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #5A6480;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #EF4444;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
color: #1A1F36;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(108, 99, 255, 0.4);
|
||||
background: rgba(108, 99, 255, 0.05);
|
||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #3A4460;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
/* Submit button */
|
||||
.login-submit-btn {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #6C63FF, #A78BFA);
|
||||
height: 46px;
|
||||
background: linear-gradient(135deg, #5B5FED, #6C63FF);
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-top: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-submit-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 20px rgba(108, 99, 255, 0.4);
|
||||
.login-submit-btn:hover {
|
||||
background: linear-gradient(135deg, #4A4ECC, #5B5FED);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-submit-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Register method tabs */
|
||||
.login-submit-btn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Register Method Tabs */
|
||||
.register-method-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.register-method-tab {
|
||||
@@ -273,32 +261,32 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: transparent;
|
||||
color: #5A6480;
|
||||
font-size: 13px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.register-method-tab:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.register-method-tab.active {
|
||||
border-color: rgba(108, 99, 255, 0.3);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
color: #8B85FF;
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.register-method-tab:hover:not(.active) {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #5A6480;
|
||||
}
|
||||
|
||||
/* SMS row */
|
||||
/* SMS Row */
|
||||
.sms-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.sms-input {
|
||||
@@ -306,42 +294,49 @@
|
||||
}
|
||||
|
||||
.sms-btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(108, 99, 255, 0.2);
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
color: #8B85FF;
|
||||
font-size: 13px;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-accent);
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.sms-btn:hover:not(:disabled) {
|
||||
background: rgba(108, 99, 255, 0.15);
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sms-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Required Marker */
|
||||
.required {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
flex-direction: column;
|
||||
width: calc(100vw - 32px);
|
||||
width: 90vw;
|
||||
min-height: auto;
|
||||
margin: 16px;
|
||||
}
|
||||
.login-brand-panel {
|
||||
width: 100%;
|
||||
padding: 32px 24px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding: var(--space-3xl) var(--space-2xl);
|
||||
}
|
||||
.login-form-panel {
|
||||
padding: 32px 24px;
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
.brand-features {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -65,22 +65,38 @@ const LoginPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await authAPI.login(loginAccount, loginPassword);
|
||||
const data: any = (res as any).data || res;
|
||||
const token = data.access_token || data.token;
|
||||
const userData = data.user || data;
|
||||
localStorage.setItem('aiflow_token', token);
|
||||
const { access_token, user: userData } = res.data;
|
||||
localStorage.setItem('aiflow_token', access_token);
|
||||
const userObj: User = {
|
||||
id: userData.id, username: userData.username,
|
||||
email: userData.email || '', phone: userData.phone || '',
|
||||
avatar: userData.avatar || '', role: userData.role || 'user',
|
||||
status: 'active', realNameStatus: 'unverified',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || '',
|
||||
avatar: userData.avatar || '',
|
||||
role: userData.role,
|
||||
status: userData.status,
|
||||
realNameStatus: userData.real_name_status || 'unverified',
|
||||
createdAt: userData.created_at || new Date().toISOString(),
|
||||
membership: userData.membership_level > 0 ? {
|
||||
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
|
||||
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
||||
level: userData.membership_level,
|
||||
expireAt: userData.membership_expire_at || '',
|
||||
autoRenew: userData.auto_renew || false,
|
||||
} : undefined,
|
||||
};
|
||||
login(userObj);
|
||||
navigate(userObj.role === 'admin' ? '/admin' : '/chat');
|
||||
navigate(userData.role === 'admin' ? '/admin' : '/chat');
|
||||
} catch (e: any) {
|
||||
const msg = (e as any).detail || (e as any).message || '';
|
||||
if (msg.includes('Network') || msg.includes('fetch')) {
|
||||
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
|
||||
const status = e.status || e.response?.status || 0;
|
||||
const msg = e.detail || e.message || '';
|
||||
if (status === 403) {
|
||||
setErrorMsg('账号已被禁用,请联系管理员');
|
||||
} else if (status === 401) {
|
||||
// 后端的detail精确指明了原因:"用户名不存在" 或 "密码错误"
|
||||
setErrorMsg(msg || '用户名或密码错误');
|
||||
} else if (status === 0 || msg.includes('NetworkError') || msg.includes('Failed to fetch')) {
|
||||
setErrorMsg('网络连接失败,请检查网络');
|
||||
} else {
|
||||
setErrorMsg(msg || '登录失败,请稍后重试');
|
||||
@@ -105,10 +121,14 @@ const LoginPage: React.FC = () => {
|
||||
const { access_token, user: userData } = res.data;
|
||||
localStorage.setItem('aiflow_token', access_token);
|
||||
const userObj: User = {
|
||||
id: userData.id, username: userData.username,
|
||||
email: userData.email || '', phone: userData.phone || '',
|
||||
avatar: userData.avatar || '', role: 'user',
|
||||
status: 'active', realNameStatus: 'unverified',
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || '',
|
||||
avatar: userData.avatar || '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
realNameStatus: 'unverified',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
login(userObj);
|
||||
@@ -138,18 +158,12 @@ const LoginPage: React.FC = () => {
|
||||
<div className="brand-content">
|
||||
<div className="brand-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 32 32" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="lpBrandGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6C63FF"/>
|
||||
<stop offset="100%" stopColor="#A78BFA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#lpBrandGrad)"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
<rect width="32" height="32" rx="8" fill="white"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#5B5FED" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="#5B5FED" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="brand-title">七虎AI</h1>
|
||||
<h1 className="brand-title">AIFlow</h1>
|
||||
<p className="brand-desc">智能自动化PaaS平台</p>
|
||||
<div className="brand-features">
|
||||
<div className="brand-feature"><span className="bf-icon">✓</span><span>一站式AI自动化SOP</span></div>
|
||||
|
||||
+1606
-1637
File diff suppressed because it is too large.
Load diff
+1578
-1609
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
bottom: -20px;
|
||||
left: -20px;
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { imUserAPI } from '@/api';
|
||||
|
||||
const WechatBot: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<any>({ connected: false, configured: false });
|
||||
const [qrcode, setQrcode] = useState<any>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
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 loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await imUserAPI.getStatus();
|
||||
if ((res as any)?.success) setStatus(res.data || { connected: false, configured: false });
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
// 获取扫码二维码(iLink 协议免 AppID)
|
||||
const handleQrcode = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await imUserAPI.qrcode();
|
||||
if ((res as any)?.success && (res as any).data) {
|
||||
setQrcode((res as any).data);
|
||||
setPolling(true);
|
||||
showToast('二维码已生成,请用手机微信扫码');
|
||||
} else {
|
||||
showToast((res as any).error || '获取二维码失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('获取二维码失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询扫码状态
|
||||
const handlePoll = useCallback(async () => {
|
||||
if (!qrcode?.qrcode) return;
|
||||
try {
|
||||
const res = await imUserAPI.qrcodeStatus(qrcode.qrcode);
|
||||
if ((res as any)?.success && (res as any).data) {
|
||||
const d = (res as any).data;
|
||||
if (d.status === 'confirmed' || d.connected) {
|
||||
setPolling(false);
|
||||
setReconnecting(false);
|
||||
setStatus({ ...status, connected: true, configured: true });
|
||||
showToast('个人微信已连接!');
|
||||
loadStatus();
|
||||
} else if (d.status === 'expired') {
|
||||
setPolling(false);
|
||||
showToast('二维码已过期,请重新获取', 'error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, [qrcode, status, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (polling && qrcode?.qrcode) {
|
||||
const t = setInterval(() => handlePoll(), 3000);
|
||||
return () => clearInterval(t);
|
||||
}
|
||||
}, [polling, qrcode, handlePoll]);
|
||||
|
||||
// 断开连接
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await imUserAPI.disconnect();
|
||||
setPolling(false);
|
||||
setQrcode(null);
|
||||
setReconnecting(false);
|
||||
setStatus({ connected: false, configured: false });
|
||||
showToast('已断开个人微信连接');
|
||||
loadStatus();
|
||||
} catch (e) {
|
||||
showToast('断开失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
|
||||
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>我的微信机器人</h1>
|
||||
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
|
||||
绑定你自己的个人微信,由七虎AI 大脑自动应答。基于腾讯官方 iLink 协议,扫码授权即可,无需任何开放平台凭证。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
|
||||
padding: '12px 24px', borderRadius: '8px',
|
||||
background: toast.type === 'success' ? '#10b981' : '#ef4444',
|
||||
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>{toast.msg}</div>
|
||||
)}
|
||||
|
||||
{/* 状态条 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#F8F9FC', borderRadius: '12px', padding: '16px 24px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', color: '#1A1F36' }}>个人微信(iLink 协议)</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#6B7280' }}>
|
||||
直接对接腾讯官方 iLink Bot HTTP API,扫码授权后由七虎AI 自动回复。
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
|
||||
background: status.connected ? '#10b98120' : status.status === 'expired' ? '#ef444420' : '#6b728020',
|
||||
color: status.connected ? '#10b981' : status.status === 'expired' ? '#ef4444' : '#6b7280',
|
||||
}}>
|
||||
{status.connected ? '● 已连接' : status.status === 'expired' ? '⚠ 会话已过期' : status.configured ? '○ 已配置' : '○ 未绑定'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 扫码绑定 */}
|
||||
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
|
||||
{status.status === 'expired' && (
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#b91c1c', borderRadius: '8px', padding: '12px 14px', marginBottom: '16px', fontSize: '13px', lineHeight: '1.7' }}>
|
||||
⚠ <b>微信会话已失效</b>:检测到与微信的连接已过期(通常是服务更新/重启后导致),消息无法再送达。请点击下方「获取扫码二维码」<b>重新扫码授权</b>即可恢复,历史对话记录仍保留。
|
||||
</div>
|
||||
)}
|
||||
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>扫码授权绑定</h4>
|
||||
{status.connected && !reconnecting ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ fontSize: '13px', color: '#10b981' }}>已绑定,你的个人微信正在自动收发消息并由七虎AI 应答。</div>
|
||||
<div>
|
||||
<button onClick={() => { setReconnecting(true); handleQrcode(); }} style={secondaryBtnStyle}>重新连接(重扫二维码)</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#9CA3AF' }}>若发现消息收不到,可点此重新扫码以重建与微信的会话。</div>
|
||||
</div>
|
||||
) : !qrcode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280' }}>
|
||||
点击「获取扫码二维码」,使用手机微信扫描二维码并确认授权,即可将你的个人微信接入七虎AI。整个过程无需申请任何开放平台凭证。
|
||||
</div>
|
||||
<button onClick={handleQrcode} disabled={loading} style={primaryBtnStyle}>
|
||||
{loading ? '生成中…' : '获取扫码二维码'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
{qrcode.qrcode_img ? (
|
||||
<img src={qrcode.qrcode_img} alt="微信扫码" style={{ width: '220px', height: '220px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }} />
|
||||
) : (
|
||||
<div style={{ width: '220px', height: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', fontSize: '12px', color: '#9CA3AF', textAlign: 'center', padding: '12px' }}>
|
||||
二维码图片生成失败,<a href={qrcode.qrcode_img_content} target="_blank" rel="noreferrer" style={{color:'#6C63FF'}}>请点击此处在新标签页打开</a>。
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '13px', color: polling ? '#f59e0b' : '#6B7280' }}>
|
||||
{polling ? '等待扫码中…(每 3 秒自动检测一次)' : '已停止轮询'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
{polling && (
|
||||
<button onClick={() => setPolling(false)} style={secondaryBtnStyle}>取消轮询</button>
|
||||
)}
|
||||
<button onClick={handleDisconnect} style={{ ...secondaryBtnStyle, color: '#ef4444', borderColor: '#ef4444' }}>断开连接</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已连接信息 */}
|
||||
{status.connected && (
|
||||
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
|
||||
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>连接信息</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<DetailItem label="绑定账号" value={status.username || '-'} />
|
||||
<DetailItem label="连接时间" value={formatTime(status.connected_at)} />
|
||||
<DetailItem label="iLink Bot ID" value={status.ilink_bot_id || '-'} />
|
||||
<DetailItem label="Base URL" value={status.baseurl || '-'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 说明 */}
|
||||
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>接入说明</h4>
|
||||
<ul style={{ fontSize: '13px', color: '#6B7280', lineHeight: '1.8', paddingLeft: '20px', margin: 0 }}>
|
||||
<li>每个账号独立绑定自己的个人微信,数据严格按账号隔离,互不影响</li>
|
||||
<li>个人微信通过腾讯官方 iLink Bot HTTP API(微信 ClawBot 插件)接入,消息经七虎AI 大脑生成回复</li>
|
||||
<li>采用官方合法协议,无需 Hook / 逆向,封号风险远低于历史方案,但仍请遵守微信平台规范</li>
|
||||
<li>消息收发基于长轮询(getupdates)+ 官方 sendmessage,回复自动关联对话窗口(context_token)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailItem: React.FC<{ label: string; value: any }> = ({ label, value }) => (
|
||||
<div style={{ padding: '8px 12px', background: '#FFFFFF', borderRadius: '6px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{label}</div>
|
||||
<div style={{ fontSize: '14px', marginTop: '2px', wordBreak: 'break-all' }}>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
|
||||
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
|
||||
};
|
||||
|
||||
const secondaryBtnStyle: React.CSSProperties = {
|
||||
padding: '8px 20px', background: '#F0F2F8', border: '1px solid #F0F2F8', borderRadius: '6px',
|
||||
color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
|
||||
};
|
||||
|
||||
export default WechatBot;
|
||||
@@ -1,97 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { imUserAPI } from '@/api';
|
||||
|
||||
const WechatHistory: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await imUserAPI.history();
|
||||
if ((res as any)?.success && (res as any).data) {
|
||||
setMessages((res as any).data.messages || []);
|
||||
} else {
|
||||
showToast('加载失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('加载失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
|
||||
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>微信对话记录</h1>
|
||||
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
|
||||
你通过个人微信与七虎AI 的对话历史,按时间顺序记录,可随时回看(仅本人可见,按账号隔离)。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={load} disabled={loading} style={btnStyle}>{loading ? '刷新中…' : '刷新'}</button>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
|
||||
padding: '12px 24px', borderRadius: '8px',
|
||||
background: toast.type === 'success' ? '#10b981' : '#ef4444',
|
||||
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>{toast.msg}</div>
|
||||
)}
|
||||
|
||||
{/* 记录列表 */}
|
||||
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '16px', minHeight: '420px' }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '380px', color: '#9CA3AF', fontSize: '14px', textAlign: 'center', padding: '0 24px' }}>
|
||||
{loading ? '加载中…' : '暂无对话记录。请先在「微信机器人」中绑定并发送消息,对话记录会显示在这里。'}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '14px' }}>
|
||||
<div style={{ maxWidth: '78%' }}>
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF', marginBottom: '4px', textAlign: m.role === 'user' ? 'right' : 'left' }}>
|
||||
{m.role === 'user' ? '我(微信)' : '七虎AI'} · {formatTime(m.created_at)}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
background: m.role === 'user' ? '#6C63FF' : '#FFFFFF',
|
||||
color: m.role === 'user' ? '#fff' : '#1A1F36',
|
||||
border: m.role === 'user' ? 'none' : '1px solid #E5E7EB',
|
||||
}}>{m.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
|
||||
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
|
||||
};
|
||||
|
||||
export default WechatHistory;
|
||||
+263
-769
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user