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:
qyyfw committed 2026-07-11 06:29:32 +08:00
1 parent 1346fbbd4b
commit 5d7f32db0a
138 files changed
+12846 -84248

No files matched your search

+36 -532
View File
@@ -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
+95 -206
View File
@@ -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>
);
+19 -71
View File
@@ -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;
}
+146 -183
View File
@@ -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;
-491
View File
@@ -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
-701
View File
@@ -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;
-207
View File
@@ -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;
-519
View File
@@ -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;
}
-799
View File
@@ -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>
);
}
-599
View File
@@ -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;
-209
View File
@@ -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;
-747
View File
@@ -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;
+227 -385
View File
@@ -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;
-155
View File
@@ -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; }
-515
View File
@@ -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>
);
}
-537
View File
@@ -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: [] }); }}>
&larr; Back to list
</button>
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
</div>
{testResult && (
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
<strong>Test Result: </strong>
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
</div>
)}
<table className="dev-table">
<thead>
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
</thead>
<tbody>
{deliveries.items.map(d => (
<tr key={d.id}>
<td className="mono-text">{d.event}</td>
<td>{statusBadge(d.status)}</td>
<td>{d.response_code || '-'}</td>
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
<td>{d.attempts}</td>
<td>{fmtTime(d.created_at)}</td>
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
</tr>
))}
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
</tbody>
</table>
{deliveries.total > 20 && (
<div className="dev-pagination">
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
</div>
)}
</div>
) : (
/* Webhook List View */
<div>
<div className="dev-toolbar">
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
</thead>
<tbody>
{webhooks.items.map(w => (
<tr key={w.id}>
<td>{w.name}</td>
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
<td>
{w.events.length > 0 ? (
<span>{w.events.length} events</span>
) : <span className="dev-muted">All events</span>}
</td>
<td>{statusBadge(w.status)}</td>
<td>{w.total_deliveries}</td>
<td>
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
{w.success_rate}%
</span>
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
</td>
<td>{fmtTime(w.last_triggered_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
</td>
</tr>
))}
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
</tbody>
</table>
{webhooks.total > 20 && (
<div className="dev-pagination">
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
</div>
)}
{/* 支持的事件 Info */}
{webhookEvents.length > 0 && (
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
{webhookEvents.map(ev => (
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create Webhook Modal */}
{showWebhookModal && (
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建 Webhook</h3>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
</div>
<div className="dev-form-group">
<label>URL</label>
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
</div>
<div className="dev-form-group">
<label>Events (leave empty for all)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
{webhookEvents.map(ev => (
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
}} />
{ev.label}
</label>
))}
</div>
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
</div>
</div>
</div>
)}
</div>
)}
{/* ===== SDK Download Center Tab ===== */}
{activeTab === 'sdk' && !loading && (
<div>
{/* Language Selector */}
<div className="dev-toolbar">
{sdkPackages.map(pkg => (
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
{pkg.name}
</button>
))}
</div>
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
{/* Package Info */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
</div>
</div>
{/* Install Command */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
{pkg.install_cmd}
</code>
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
</div>
</div>
{/* 功能特性 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{pkg.features.map(f => (
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
{f}
</span>
))}
</div>
</div>
{/* Code Example */}
<div>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
<div style={{ position: 'relative' }}>
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
<code>{pkg.code_example}</code>
</pre>
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
</div>
</div>
</div>
))}
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
</div>
)}
{/* ===== Interactive API Docs Tab ===== */}
{activeTab === 'docs' && !loading && (
<div>
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
</div>
{docSections.map(section => {
const isExpanded = expandedSection === section.section;
return (
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
{/* Section Header */}
<div
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '18px' }}>
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
</span>
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
</div>
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
</div>
{/* Section Content */}
{isExpanded && (
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
{section.endpoints.map(ep => {
const epKey = `${section.section}-${ep.path}`;
const isEpExpanded = expandedEndpoint === epKey;
return (
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
<div
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
>
{methodBadge(ep.method)}
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
</div>
{isEpExpanded && (
<div style={{ padding: '0 16px 16px 16px' }}>
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
{/* 参数 */}
{ep.params.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
<table className="dev-table" style={{ fontSize: '13px' }}>
<thead>
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
</thead>
<tbody>
{ep.params.map(p => (
<tr key={p.name}>
<td className="mono-text">{p.name}</td>
<td>{p.type}</td>
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
<td>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 请求示例 */}
{ep.example_request && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_request}</code>
</pre>
</div>
)}
{/* 响应示例 */}
{ep.example_response && (
<div>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_response}</code>
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
</div>
)}
</div>
</div>
);
}
@@ -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
-538
View File
@@ -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;
File diff suppressed because it is too large. Load diff
-405
View File
@@ -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>
);
}
-729
View File
@@ -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
View File
@@ -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
-520
View File
@@ -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
-665
View File
@@ -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
-235
View File
@@ -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;
-757
View File
@@ -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",
};
}
File diff suppressed because it is too large. Load diff
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
+370 -486
View File
@@ -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>&copy; 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;
+153 -158
View File
@@ -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;
}
}
+40 -26
View File
@@ -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">&#x2713;</span><span>一站式AI自动化SOP</span></div>
File diff suppressed because it is too large. Load diff
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;
}
-228
View File
@@ -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;
File diff suppressed because it is too large. Load diff