feat(frontend): HashRouter->BrowserRouter 干净URL可独立索引 + 静态预渲染(SSR注入#root/robots/sitemap) + logo回顶
This commit is contained in:
1 parent
a1bea39d9f
commit
d404aa4e29
12 files changed
+1387
-1135
No files matched your search
@@ -0,0 +1,84 @@
|
||||
/* 静态预渲染:把 LandingPage 渲染为静态 HTML 注入 dist/index.html 的 #root,
|
||||
让 Google / Bing / ChatGPT / Perplexity 等不执行 JS 的爬虫也能读到真实正文。
|
||||
同时生成 robots.txt 与 sitemap.xml。 */
|
||||
const esbuild = require('esbuild');
|
||||
const React = require('react');
|
||||
const ReactDOMServer = require('react-dom/server');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FRONT = '/app/frontend';
|
||||
const OUT = path.join(FRONT, 'dist');
|
||||
|
||||
// 1) 用 esbuild 把组件打包成 CJS(react/react-dom/react-router-dom 保持 external,复用运行时同一实例)
|
||||
esbuild.buildSync({
|
||||
entryPoints: [path.join(FRONT, 'prerender-entry.tsx')],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
outfile: path.join(FRONT, 'prerender-bundle.cjs'),
|
||||
alias: { '@': path.join(FRONT, 'src') },
|
||||
loader: { '.css': 'empty', '.tsx': 'tsx', '.ts': 'ts' },
|
||||
jsx: 'automatic',
|
||||
external: ['react', 'react-dom', 'react-router-dom'],
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
// 2) 服务端渲染为静态标记
|
||||
const mod = require(path.join(FRONT, 'prerender-bundle.cjs'));
|
||||
const ssrHtml = ReactDOMServer.renderToStaticMarkup(React.createElement(mod.App));
|
||||
console.log('[prerender] ssrHtml length =', ssrHtml.length);
|
||||
|
||||
// 3) 注入 dist/index.html 的 #root
|
||||
let html = fs.readFileSync(path.join(OUT, 'index.html'), 'utf8');
|
||||
const rootOpen = '<div id="root"></div>';
|
||||
if (html.includes(rootOpen)) {
|
||||
html = html.replace(rootOpen, '<div id="root">' + ssrHtml + '</div>');
|
||||
} else {
|
||||
html = html.replace(/<div id="root">[\s\S]*?<\/div>\s*<\/body>/,
|
||||
'<div id="root">' + ssrHtml + '</div></body>');
|
||||
}
|
||||
|
||||
// 4) 注入 SEO/GEO <head> 元数据(head 中的 JSON-LD 在 JS 挂载后仍保留;#root 内由组件渲染的 JSON-LD 供非JS爬虫读取)
|
||||
const title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式 PaaS 平台';
|
||||
const desc = '七虎AI 是面向个人与职场用户的 AI PaaS 平台:低价订阅无限 Token、垂直办公模板、可交易技能市场、AI 创作试炼场与低成本养模型,让每个人用 AI 把事做成。';
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: '七虎AI',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
|
||||
aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.8', reviewCount: '1000' },
|
||||
url: 'https://ai.qihuyun.com/',
|
||||
};
|
||||
const headInject = [
|
||||
` <title>${title}</title>`,
|
||||
` <meta name="description" content="${desc}" />`,
|
||||
` <meta property="og:title" content="${title}" />`,
|
||||
` <meta property="og:description" content="${desc}" />`,
|
||||
` <meta property="og:type" content="website" />`,
|
||||
` <meta name="twitter:card" content="summary_large_image" />`,
|
||||
` <link rel="canonical" href="https://ai.qihuyun.com/" />`,
|
||||
` <script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`,
|
||||
].join('\n');
|
||||
html = html.replace('</head>', headInject + '\n </head>');
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'index.html'), html);
|
||||
|
||||
// 5) robots.txt
|
||||
fs.writeFileSync(path.join(OUT, 'robots.txt'),
|
||||
'User-agent: *\nAllow: /\n\nSitemap: https://ai.qihuyun.com/sitemap.xml\n');
|
||||
|
||||
// 6) sitemap.xml(当前公开可索引页仅首页;其余为登录后页面,按需在后续迭代补充)
|
||||
const urls = ['https://ai.qihuyun.com/'];
|
||||
const sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
||||
urls.map(u => ` <url><loc>${u}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`).join('\n') +
|
||||
'\n</urlset>\n';
|
||||
fs.writeFileSync(path.join(OUT, 'sitemap.xml'), sitemap);
|
||||
|
||||
// 7) 清理临时文件
|
||||
[path.join(FRONT, 'prerender-entry.tsx'), path.join(FRONT, 'prerender-bundle.cjs')].forEach(f => fs.existsSync(f) && fs.unlinkSync(f));
|
||||
|
||||
console.log('PRERENDER DONE');
|
||||
+208
-19
@@ -38,7 +38,7 @@ export async function request<T>(
|
||||
// 其他接口的401 = token过期/未登录,清除状态并跳转
|
||||
localStorage.removeItem('aiflow_token');
|
||||
localStorage.removeItem('aiflow_user');
|
||||
window.location.hash = '#/login';
|
||||
window.location.href = '/login';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -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_ids?: string[]; use_tools?: boolean }) =>
|
||||
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_base_ids?: string[]; use_tools?: boolean }) =>
|
||||
post<any>(`/chat/conversations/${convId}/messages`, data),
|
||||
clearContext: (convId: string) =>
|
||||
post<any>(`/chat/conversations/${convId}/clear`),
|
||||
convertToKnowledgeGraph: (convId: string) =>
|
||||
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
|
||||
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),
|
||||
@@ -315,11 +315,6 @@ export const adminAPI = {
|
||||
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),
|
||||
@@ -724,16 +719,7 @@ export const adminAPI = {
|
||||
getTenantMembers: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/members`),
|
||||
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
||||
|
||||
|
||||
// ---- 以下 4 个方法为 1346fbb 之后新增, 保留 ----
|
||||
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
|
||||
getAdminApps: () => get<any[]>('/admin/market/apps'),
|
||||
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
||||
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
|
||||
|
||||
};
|
||||
|
||||
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
// 驾驶舱面板
|
||||
@@ -803,9 +789,212 @@ 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 个人微信(前台用户) =====
|
||||
|
||||
// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
|
||||
export const imUserAPI = {
|
||||
getStatus: () => get<any>('/im/wechat/status'),
|
||||
qrcode: () => post<any>('/im/wechat/qrcode'),
|
||||
|
||||
@@ -1,121 +1,233 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import './AdminLayout.css';
|
||||
|
||||
const adminNavItems = [
|
||||
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/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/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 adminIconPaths: Record<string, JSX.Element> = {
|
||||
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>,
|
||||
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>,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
navigate('/login');
|
||||
return null;
|
||||
// Default: all groups collapsed. Auto-expand the group containing the active route.
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>();
|
||||
const currentPath = location.pathname;
|
||||
for (const group of adminNavGroups) {
|
||||
const isActive = group.items.some(item =>
|
||||
item.exact ? currentPath === item.path : currentPath.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.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
{/* Admin Sidebar */}
|
||||
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-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="#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>}
|
||||
<h1 className="admin-logo">七虎AI</h1>
|
||||
<span className="admin-subtitle">管理控制台</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 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 ? location.pathname === item.path : location.pathname.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>
|
||||
|
||||
<nav className="admin-nav">
|
||||
{adminNavItems.map(item => (
|
||||
{isExpanded && (
|
||||
<div className="nav-group-items">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.exact}
|
||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||
className={({ isActive }) =>
|
||||
`nav-item ${isActive ? 'nav-item-active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
|
||||
<span className="nav-item-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="admin-sidebar-footer">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<main className="admin-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
@@ -8,6 +8,28 @@ const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ child
|
||||
</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> },
|
||||
@@ -18,9 +40,86 @@ const quickEntries = [
|
||||
{ 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({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
|
||||
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);
|
||||
@@ -29,14 +128,27 @@ const Dashboard: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, logsRes, auditRes] = await Promise.all([
|
||||
adminAPI.getStats(),
|
||||
adminAPI.getRecentLogs(),
|
||||
adminAPI.getRecentAudit(),
|
||||
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,
|
||||
]);
|
||||
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 : []);
|
||||
// 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 {
|
||||
@@ -54,15 +166,24 @@ const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
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 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> },
|
||||
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) {
|
||||
@@ -78,85 +199,121 @@ const Dashboard: React.FC = () => {
|
||||
<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)' }}>
|
||||
{/* Stats Cards Row */}
|
||||
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 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: 'var(--radius-lg)',
|
||||
width: 48, height: 48, borderRadius: 14,
|
||||
background: s.bg, color: s.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
<div className="admin-stat-value">{s.value}</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>
|
||||
|
||||
{/* Platform Cumulative Token Stats */}
|
||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
{/* Charts Row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 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消耗
|
||||
{' '}Token消耗趋势(近30天)
|
||||
</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 style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
||||
<BarChart data={dailyStats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Entry Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
|
||||
{/* 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-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
|
||||
{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)',
|
||||
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 = 'var(--color-accent)';
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
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: '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 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 Operation Logs */}
|
||||
<div className="admin-card">
|
||||
{/* 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">
|
||||
@@ -192,6 +349,7 @@ const Dashboard: React.FC = () => {
|
||||
<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">
|
||||
|
||||
@@ -4,40 +4,86 @@ import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
|
||||
import type { Conversation, Message } from '@/types';
|
||||
import './ChatPage.css';
|
||||
|
||||
// ===== Simple Markdown Renderer =====
|
||||
// ===== Enhanced Markdown Renderer =====
|
||||
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
||||
const html = useMemo(() => {
|
||||
let text = content;
|
||||
// Code blocks (```)
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => {
|
||||
return `<pre class="md-code-block"><code class="md-code">${escapeHtml(code.trim())}</code></pre>`;
|
||||
|
||||
// 1. Extract code blocks first (protect from other replacements)
|
||||
const codeBlocks: string[] = [];
|
||||
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
|
||||
const idx = codeBlocks.length;
|
||||
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
|
||||
return '\x00CODEBLOCK' + idx + '\x00';
|
||||
});
|
||||
// Inline code (`)
|
||||
text = text.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
|
||||
// Bold (**text**)
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic (*text*)
|
||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Headers (## heading)
|
||||
|
||||
// 2. Extract inline code
|
||||
const inlineCodes: string[] = [];
|
||||
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
|
||||
const idx = inlineCodes.length;
|
||||
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
|
||||
return '\x00INLINE' + idx + '\x00';
|
||||
});
|
||||
|
||||
// 2.5 Extract media blocks (<img>/<video>) to protect from other replacements
|
||||
const mediaBlocks: string[] = [];
|
||||
text = text.replace(/<(img|video)[^>]*\/?>/gi, (_m: string) => {
|
||||
const idx = mediaBlocks.length;
|
||||
mediaBlocks.push(_m);
|
||||
return '\x00MEDIA' + idx + '\x00';
|
||||
});
|
||||
|
||||
// 3. Headers
|
||||
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
|
||||
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
|
||||
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
|
||||
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
|
||||
// Unordered list items
|
||||
text = text.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>');
|
||||
// Ordered list items
|
||||
text = text.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
|
||||
// Horizontal rule
|
||||
text = text.replace(/^---$/gm, '<hr class="md-hr">');
|
||||
// Paragraph spacing: double newlines
|
||||
text = text.replace(/\n\n/g, '<br/><br/>');
|
||||
// Single newlines to <br>
|
||||
|
||||
// 4. Horizontal rule
|
||||
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
|
||||
|
||||
// 5. Blockquotes
|
||||
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
|
||||
|
||||
// 6. Links [text](url)
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
|
||||
// 7. Bold and italic
|
||||
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
|
||||
|
||||
// 8. Lists
|
||||
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
|
||||
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
|
||||
// Wrap consecutive <li> in <ul>
|
||||
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
|
||||
|
||||
// 9. Convert double newlines to paragraph breaks, single to <br/>
|
||||
text = text.replace(/\n\n/g, '</p><p class="md-p">');
|
||||
text = text.replace(/\n/g, '<br/>');
|
||||
|
||||
// 10. Restore code blocks and inline codes
|
||||
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
|
||||
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
|
||||
text = text.replace(/\x00MEDIA(\d+)\x00/g, (_m: string, idx: string) => mediaBlocks[parseInt(idx)] || '');
|
||||
|
||||
// 11. Wrap in paragraph and clean up
|
||||
text = '<p class="md-p">' + text + '</p>';
|
||||
text = text.replace(/<p class="md-p"><\/p>/g, '');
|
||||
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
|
||||
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
|
||||
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
|
||||
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
|
||||
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
|
||||
|
||||
return text;
|
||||
}, [content]);
|
||||
|
||||
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
};
|
||||
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
@@ -387,16 +433,16 @@ const ChatPage: React.FC = () => {
|
||||
let apps: any[] = [];
|
||||
if (Array.isArray(data)) apps = data;
|
||||
else if (data?.items) apps = data.items;
|
||||
// 过滤已卸载和未安装的agent
|
||||
setAgents(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
|
||||
// 显示所有已发布的Agent(不需要安装即可在聊天中使用)
|
||||
setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||
}).catch(() => {});
|
||||
marketAPI.getApps({ type: 'skill' }).then((res: any) => {
|
||||
const data = res?.data !== undefined ? res.data : res;
|
||||
let apps: any[] = [];
|
||||
if (Array.isArray(data)) apps = data;
|
||||
else if (data?.items) apps = data.items;
|
||||
// 过滤已卸载和未安装的skill
|
||||
setSkills(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
|
||||
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
|
||||
setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -547,8 +593,10 @@ const ChatPage: React.FC = () => {
|
||||
kbName: res.data.knowledge_base_name,
|
||||
kbId: res.data.knowledge_base_id,
|
||||
});
|
||||
// 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
|
||||
const newCount = res.data.knowledge_graph_count ?? 1;
|
||||
setConversations(prev =>
|
||||
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: (c.knowledgeGraphCount || 0) + 1 } : c)
|
||||
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
|
||||
);
|
||||
} else {
|
||||
setKgError(res.message || '转换失败');
|
||||
@@ -582,7 +630,7 @@ const ChatPage: React.FC = () => {
|
||||
model_id: selectedModelId || undefined,
|
||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||||
knowledge_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||||
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
|
||||
});
|
||||
|
||||
@@ -753,6 +801,13 @@ const ChatPage: React.FC = () => {
|
||||
<div className="model-dropdown-item">
|
||||
<span>{model.name}</span>
|
||||
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span>
|
||||
{(() => {
|
||||
const caps = model.capabilities || [];
|
||||
const label = caps.includes('image') && caps.includes('video') ? '图像/视频'
|
||||
: caps.includes('image') ? '图像'
|
||||
: caps.includes('video') ? '视频' : '';
|
||||
return label ? <span className="model-cap-badge">{label}</span> : null;
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -797,7 +852,7 @@ const ChatPage: React.FC = () => {
|
||||
<span
|
||||
key={skillName}
|
||||
className="evo-skill-tag"
|
||||
onClick={() => window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`}
|
||||
onClick={() => { window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
|
||||
title={`使用了专属技能:${skillName}`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -1,704 +1,258 @@
|
||||
/* ===== Landing Page Styles ===== */
|
||||
/* ===== 七虎AI 前台首页 · 遵循 DESIGN.md(亮色/单一靛蓝/渐变/玻璃拟态/SEO) ===== */
|
||||
/* LandingPage 不在 FrontLayout 壳内,而 global.css 全局锁死了 html/body/#root 的滚动;
|
||||
故本页自成一个可滚动容器,避免内容超出 100vh 被裁切且无法翻阅。 */
|
||||
.lp { color: var(--color-text-primary); background: var(--color-bg-primary); height: 100vh; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
.landing-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--color-bg-primary);
|
||||
z-index: var(--z-base);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* 玻璃拟态工具类(DESIGN.md §4) */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.landing-toast {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
padding: 10px 24px;
|
||||
background: var(--color-text-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideDown var(--transition-normal);
|
||||
white-space: nowrap;
|
||||
/* ===== 顶栏 ===== */
|
||||
.lp-header {
|
||||
position: sticky; top: 0; z-index: var(--z-sticky);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.lp-header-inner {
|
||||
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
|
||||
height: 64px; display: flex; align-items: center; gap: var(--space-2xl);
|
||||
}
|
||||
.lp-brand { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.brand-name { font-weight: 700; font-size: var(--text-lg); letter-spacing: .2px; }
|
||||
.lp-nav { display: flex; gap: var(--space-xl); flex: 1; }
|
||||
.lp-nav a {
|
||||
color: var(--color-text-secondary); text-decoration: none; font-size: var(--text-base);
|
||||
font-weight: 500; cursor: pointer; transition: color var(--transition-fast);
|
||||
}
|
||||
.lp-nav a:hover { color: var(--color-accent); }
|
||||
.lp-header-actions { display: flex; gap: var(--space-sm); align-items: center; }
|
||||
|
||||
.landing-section-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
/* 按钮(DESIGN.md §4) */
|
||||
.lp-btn {
|
||||
font-family: var(--font-sans); font-size: var(--text-base); font-weight: 600;
|
||||
border-radius: var(--radius-md); padding: 9px 18px; cursor: pointer;
|
||||
transition: all var(--transition-normal); border: 1px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.lp-btn-lg { padding: 13px 26px; font-size: var(--text-md); }
|
||||
.lp-btn-primary { background: var(--color-accent); color: #fff; }
|
||||
.lp-btn-primary:hover { background: var(--color-accent-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.lp-btn-outline { background: transparent; color: var(--color-accent); border-color: var(--color-accent); }
|
||||
.lp-btn-outline:hover { background: var(--color-accent-subtle); transform: translateY(-1px); }
|
||||
.lp-btn-ghost { background: transparent; color: var(--color-text-secondary); }
|
||||
.lp-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
||||
|
||||
/* ===== Header ===== */
|
||||
.landing-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.lp-menu-toggle { display: none; background: transparent; border: none; color: var(--color-text-primary); cursor: pointer; margin-left: auto; }
|
||||
.lp-mobile-menu {
|
||||
display: flex; flex-direction: column; gap: var(--space-xs);
|
||||
padding: var(--space-md) var(--space-2xl) var(--space-2xl);
|
||||
border-top: 1px solid var(--color-border-light); background: var(--color-bg-secondary);
|
||||
}
|
||||
.lp-mobile-menu a { color: var(--color-text-secondary); text-decoration: none; padding: 12px 0; font-weight: 500; cursor: pointer; }
|
||||
.lp-mobile-actions { display: flex; gap: var(--space-sm); margin-top: var(--space-sm); }
|
||||
.lp-mobile-actions .lp-btn { flex: 1; }
|
||||
|
||||
.landing-header-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xl);
|
||||
}
|
||||
|
||||
.landing-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.landing-nav-links {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.landing-nav-links a {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.landing-nav-links a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
.landing-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.landing-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 2px 12px rgba(91, 95, 237, 0.35);
|
||||
}
|
||||
|
||||
.landing-btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
border: 1.5px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-btn-outline:hover {
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.landing-btn-lg {
|
||||
padding: 12px 28px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
/* ===== 通用容器 / 标题 ===== */
|
||||
.lp-inner { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl); }
|
||||
.lp-section { padding: var(--space-5xl) 0; }
|
||||
/* 锚点跳转时避开 sticky 顶栏遮挡 */
|
||||
.lp-section[id] { scroll-margin-top: 76px; }
|
||||
.lp-section-alt { background: var(--color-bg-tertiary); }
|
||||
.lp-section-head { text-align: center; max-width: 720px; margin: 0 auto var(--space-4xl); }
|
||||
.lp-h2 { font-size: var(--text-2xl); font-weight: 700; letter-spacing: -.2px; }
|
||||
.lp-sub { color: var(--color-text-secondary); font-size: var(--text-md); margin-top: var(--space-sm); }
|
||||
|
||||
/* ===== Hero ===== */
|
||||
.landing-hero {
|
||||
padding: 80px 0 72px;
|
||||
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
|
||||
}
|
||||
|
||||
.landing-hero-content {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.hero-accent {
|
||||
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 640px;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3xl);
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Carousel ===== */
|
||||
.landing-banners {
|
||||
padding: var(--space-3xl) 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: var(--space-sm) 0 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
.lp-hero {
|
||||
background: linear-gradient(180deg, #EEEDFF 0%, #F8F9FB 42%);
|
||||
padding: var(--space-5xl) 0 var(--space-4xl);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
.lp-hero-grid {
|
||||
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
|
||||
display: grid; grid-template-columns: 1.05fr .95fr; gap: var(--space-4xl); align-items: center;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
min-width: 100%;
|
||||
box-sizing: border-box;
|
||||
.lp-eyebrow {
|
||||
display: inline-block; font-size: var(--text-sm); font-weight: 600; color: var(--color-accent);
|
||||
background: var(--color-accent-light); padding: 4px 12px; border-radius: var(--radius-full); margin-bottom: var(--space-lg);
|
||||
}
|
||||
.lp-title { font-size: var(--text-3xl); font-weight: 800; line-height: 1.25; letter-spacing: -.5px; }
|
||||
.lp-accent { color: var(--color-accent); }
|
||||
.lp-desc { color: var(--color-text-secondary); font-size: var(--text-md); line-height: 1.7; margin: var(--space-lg) 0 var(--space-2xl); max-width: 520px; }
|
||||
.kw { color: var(--color-accent); font-weight: 600; }
|
||||
.lp-actions { display: flex; gap: var(--space-md); flex-wrap: wrap; }
|
||||
.lp-stats { display: flex; gap: var(--space-3xl); margin-top: var(--space-3xl); }
|
||||
.lp-stats > div { display: flex; flex-direction: column; }
|
||||
.lp-stat-num { font-size: var(--text-xl); font-weight: 800; color: var(--color-text-primary); }
|
||||
.lp-stat-label { font-size: var(--text-sm); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
|
||||
.carousel-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3xl) var(--space-3xl);
|
||||
border-radius: var(--radius-xl);
|
||||
min-height: 200px;
|
||||
color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.lp-hero-visual { position: relative; min-height: 320px; display: flex; align-items: center; justify-content: center; }
|
||||
.lp-hero-glow {
|
||||
position: absolute; width: 320px; height: 320px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(91,95,237,.22), transparent 70%);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.lp-hero-card { position: relative; width: 100%; max-width: 380px; border-radius: var(--radius-xl); padding: var(--space-2xl); display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
.lp-hc-row { display: flex; align-items: center; gap: var(--space-sm); font-size: var(--text-sm); color: var(--color-text-secondary); font-weight: 500; }
|
||||
.lp-hc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); box-shadow: 0 0 0 3px var(--color-success-light); }
|
||||
.lp-hc-bubble { background: var(--color-bg-tertiary); border-radius: var(--radius-lg); padding: var(--space-md); font-size: var(--text-sm); color: var(--color-text-primary); }
|
||||
.lp-hc-bubble-ai { background: var(--color-accent-light); color: var(--color-accent); font-weight: 500; }
|
||||
.lp-hc-bar { height: 6px; border-radius: var(--radius-full); background: var(--color-bg-tertiary); overflow: hidden; }
|
||||
.lp-hc-bar > span { display: block; height: 100%; width: 70%; border-radius: var(--radius-full); background: linear-gradient(90deg, #5B5FED, #4F8DF9); }
|
||||
|
||||
.carousel-card-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.carousel-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.carousel-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.carousel-subtitle {
|
||||
font-size: var(--text-base);
|
||||
opacity: 0.9;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.carousel-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 8px 20px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
border-radius: var(--radius-md);
|
||||
color: #FFFFFF;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.carousel-cta:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
border-color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.carousel-card-visual {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visual-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.visual-circle.c1 {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.visual-circle.c2 {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
top: 50px;
|
||||
right: 50px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.visual-circle.c3 {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* Dots */
|
||||
.carousel-dots {
|
||||
position: absolute;
|
||||
bottom: var(--space-lg);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.4);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
background: #FFFFFF;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.carousel-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover {
|
||||
background: #FFFFFF;
|
||||
/* ===== 轮播广告位 ===== */
|
||||
.lp-band { padding: var(--space-4xl) 0; }
|
||||
.lp-carousel { position: relative; }
|
||||
.lp-track { display: flex; transition: transform var(--transition-slow); }
|
||||
.lp-slide { min-width: 100%; padding: 0 var(--space-xs); }
|
||||
.lp-carousel-card {
|
||||
border-radius: var(--radius-xl); padding: var(--space-4xl); color: #fff; position: relative; overflow: hidden;
|
||||
display: flex; justify-content: space-between; align-items: center; min-height: 200px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.carousel-arrow-left {
|
||||
left: var(--space-md);
|
||||
.lp-cc-content { position: relative; z-index: 2; max-width: 60%; }
|
||||
.lp-cc-badge { display: inline-block; background: rgba(255,255,255,.22); padding: 4px 12px; border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: 600; margin-bottom: var(--space-md); }
|
||||
.lp-cc-title { font-size: var(--text-xl); font-weight: 700; }
|
||||
.lp-cc-sub { font-size: var(--text-sm); opacity: .9; margin-top: var(--space-xs); }
|
||||
.lp-cc-cta {
|
||||
margin-top: var(--space-lg); display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #fff; color: var(--color-accent); border: none; border-radius: var(--radius-md);
|
||||
padding: 9px 18px; font-weight: 600; font-size: var(--text-sm); cursor: pointer; transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.carousel-arrow-right {
|
||||
right: var(--space-md);
|
||||
.lp-cc-cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(0,0,0,.18); }
|
||||
.lp-cc-visual { position: relative; width: 120px; height: 120px; }
|
||||
.lp-cc-visual .c1, .lp-cc-visual .c2, .lp-cc-visual .c3 {
|
||||
position: absolute; border-radius: 50%; background: rgba(255,255,255,.25);
|
||||
}
|
||||
.lp-cc-visual .c1 { width: 70px; height: 70px; top: 0; right: 0; }
|
||||
.lp-cc-visual .c2 { width: 48px; height: 48px; bottom: 6px; right: 30px; }
|
||||
.lp-cc-visual .c3 { width: 30px; height: 30px; bottom: 0; left: 0; }
|
||||
|
||||
/* ===== Market ===== */
|
||||
.landing-market {
|
||||
padding: var(--space-3xl) 0 var(--space-4xl);
|
||||
background: var(--color-bg-secondary);
|
||||
.lp-arrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 3;
|
||||
width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: rgba(255,255,255,.85); color: var(--color-accent); display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: var(--shadow-md); transition: all var(--transition-normal);
|
||||
}
|
||||
.lp-arrow:hover { background: #fff; transform: translateY(-50%) scale(1.05); }
|
||||
.lp-arrow-l { left: 12px; } .lp-arrow-r { right: 12px; }
|
||||
.lp-dots { display: flex; justify-content: center; gap: var(--space-sm); margin-top: var(--space-lg); }
|
||||
.lp-dot { width: 8px; height: 8px; border-radius: 50%; border: none; background: var(--color-border-hover); cursor: pointer; transition: all var(--transition-fast); }
|
||||
.lp-dot.on { width: 24px; border-radius: var(--radius-full); background: var(--color-accent); }
|
||||
|
||||
.market-filter-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
/* ===== 栅格 ===== */
|
||||
.lp-grid { display: grid; gap: var(--space-2xl); }
|
||||
.lp-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.lp-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.lp-grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
|
||||
.filter-tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #FFFFFF;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
/* 痛点卡 */
|
||||
.lp-pain { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
|
||||
.lp-pain:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--color-border-hover); }
|
||||
.lp-pain-icon { width: 48px; height: 48px; border-radius: var(--radius-md); background: var(--color-accent-light); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
|
||||
.lp-pain-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
|
||||
.lp-pain-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
|
||||
|
||||
.filter-tab:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
/* 价值卡 */
|
||||
.lp-value { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
|
||||
.lp-value:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||
.lp-value-icon { width: 48px; height: 48px; border-radius: var(--radius-md); background: var(--gradient-brand, #EEEDFF); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
|
||||
.lp-value-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
|
||||
.lp-value-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
/* 能力卡 */
|
||||
.lp-feature { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
|
||||
.lp-feature:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||
.lp-feature-icon { width: 52px; height: 52px; border-radius: var(--radius-md); background: var(--color-accent-light); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
|
||||
.lp-feature-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
|
||||
.lp-feature-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
|
||||
|
||||
.market-app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--space-xl);
|
||||
/* ===== 热门技能榜 ===== */
|
||||
.lp-rank { list-style: none; display: flex; flex-direction: column; gap: var(--space-md); counter-reset: rank; }
|
||||
.lp-rank-item {
|
||||
display: grid; grid-template-columns: 44px 64px 1fr auto; gap: var(--space-lg); align-items: center;
|
||||
background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg) var(--space-xl); cursor: pointer; transition: all var(--transition-normal);
|
||||
}
|
||||
.lp-rank-item:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--color-accent-light); }
|
||||
.lp-rank-no { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: var(--color-bg-tertiary); color: var(--color-text-secondary); font-weight: 700; font-size: var(--text-md); }
|
||||
.lp-rank-no.top { background: linear-gradient(135deg, #5B5FED, #4F8DF9); color: #fff; box-shadow: var(--shadow-md); }
|
||||
.lp-rank-cover { position: relative; width: 64px; height: 64px; border-radius: var(--radius-md); overflow: hidden; }
|
||||
.lp-rank-badge { position: absolute; top: 6px; left: 6px; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: var(--radius-full); color: #fff; }
|
||||
.badge-skill { background: var(--color-success); }
|
||||
.badge-agent { background: var(--color-accent); }
|
||||
.badge-mcp { background: var(--color-warning); }
|
||||
.lp-rank-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.lp-rank-name { font-weight: 600; font-size: var(--text-md); }
|
||||
.lp-rank-desc { color: var(--color-text-tertiary); font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lp-rank-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; color: var(--color-text-secondary); font-size: var(--text-sm); }
|
||||
.lp-rank-installs { display: inline-flex; align-items: center; gap: 4px; color: var(--color-accent); font-weight: 600; }
|
||||
.lp-rank-rating { color: var(--color-warning); font-weight: 600; }
|
||||
|
||||
.market-app-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.market-app-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.app-card-cover {
|
||||
height: 120px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.app-card-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-card-price {
|
||||
position: absolute;
|
||||
top: var(--space-md);
|
||||
left: var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.app-card-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.app-card-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.app-card-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #F59E0B;
|
||||
}
|
||||
|
||||
.app-card-rating svg {
|
||||
fill: #F59E0B;
|
||||
}
|
||||
|
||||
.market-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-2xl);
|
||||
}
|
||||
|
||||
/* ===== Features ===== */
|
||||
.landing-features {
|
||||
padding: var(--space-4xl) 0;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: var(--space-2xl);
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
/* ===== 应用市场网格 ===== */
|
||||
.lp-app-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2xl); }
|
||||
.lp-app-card { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; transition: all var(--transition-normal); }
|
||||
.lp-app-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||
.lp-app-cover { position: relative; height: 120px; }
|
||||
.lp-app-badge { position: absolute; top: 10px; left: 10px; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: var(--radius-full); color: #fff; }
|
||||
.lp-app-price { position: absolute; top: 10px; right: 10px; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: var(--radius-full); background: rgba(255,255,255,.9); color: var(--color-text-primary); }
|
||||
.lp-app-body { padding: var(--space-lg); }
|
||||
.lp-app-name { font-size: var(--text-md); font-weight: 600; margin-bottom: 6px; }
|
||||
.lp-app-desc { color: var(--color-text-tertiary); font-size: var(--text-sm); line-height: 1.6; height: 42px; overflow: hidden; }
|
||||
.lp-app-meta { display: flex; justify-content: space-between; margin-top: var(--space-md); font-size: var(--text-xs); color: var(--color-text-secondary); }
|
||||
.lp-app-meta span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.lp-more { text-align: center; margin-top: var(--space-3xl); }
|
||||
|
||||
/* ===== CTA ===== */
|
||||
.landing-cta {
|
||||
padding: var(--space-4xl) 0;
|
||||
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.cta-desc {
|
||||
font-size: var(--text-lg);
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin: 0 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
.landing-cta .landing-btn-primary {
|
||||
background: #FFFFFF;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-cta .landing-btn-primary:hover {
|
||||
background: #F0EEFF;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.lp-cta { padding: var(--space-5xl) 0; background: linear-gradient(135deg, #5B5FED 0%, #4F8DF9 100%); }
|
||||
.lp-cta .lp-inner { text-align: center; color: #fff; }
|
||||
.lp-cta-title { font-size: var(--text-2xl); font-weight: 800; }
|
||||
.lp-cta-desc { opacity: .92; margin: var(--space-md) 0 var(--space-2xl); font-size: var(--text-md); }
|
||||
.lp-cta .lp-btn-primary { background: #fff; color: var(--color-accent); }
|
||||
.lp-cta .lp-btn-primary:hover { background: #f0f0ff; }
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.landing-footer {
|
||||
padding: var(--space-3xl) 0 var(--space-xl);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.lp-footer { background: #fff; border-top: 1px solid var(--color-border); padding: var(--space-4xl) 0 var(--space-2xl); }
|
||||
.lp-footer-grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: var(--space-2xl); }
|
||||
.lp-footer-brand p { color: var(--color-text-tertiary); font-size: var(--text-sm); margin-top: var(--space-md); max-width: 280px; line-height: 1.6; }
|
||||
.lp-footer-col h4 { font-size: var(--text-sm); font-weight: 700; margin-bottom: var(--space-md); }
|
||||
.lp-footer-col a, .lp-footer-col span { display: block; color: var(--color-text-secondary); font-size: var(--text-sm); margin-bottom: var(--space-sm); cursor: pointer; text-decoration: none; transition: color var(--transition-fast); }
|
||||
.lp-footer-col a:hover { color: var(--color-accent); }
|
||||
.lp-footer-bottom { margin-top: var(--space-3xl); padding-top: var(--space-lg); border-top: 1px solid var(--color-border-light); color: var(--color-text-tertiary); font-size: var(--text-xs); text-align: center; }
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2xl);
|
||||
margin-bottom: var(--space-2xl);
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.lp-hero-grid { grid-template-columns: 1fr; }
|
||||
.lp-hero-visual { min-height: 260px; margin-top: var(--space-2xl); }
|
||||
.lp-grid-5 { grid-template-columns: repeat(3, 1fr); }
|
||||
.lp-grid-4 { grid-template-columns: repeat(2, 1fr); }
|
||||
.lp-app-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: var(--space-4xl);
|
||||
}
|
||||
|
||||
.footer-col h4 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.footer-col a {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-col a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
padding-top: var(--space-xl);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.landing-nav-links {
|
||||
display: none;
|
||||
.lp-nav, .lp-header-actions { display: none; }
|
||||
.lp-menu-toggle { display: inline-flex; }
|
||||
.lp-section { padding: var(--space-3xl) 0; }
|
||||
.lp-grid-5, .lp-grid-4, .lp-grid-3 { grid-template-columns: repeat(2, 1fr); }
|
||||
.lp-app-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.lp-footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
.lp-stats { gap: var(--space-2xl); }
|
||||
.lp-rank-item { grid-template-columns: 36px 52px 1fr; }
|
||||
.lp-rank-meta { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.lp-inner, .lp-header-inner, .lp-hero-grid { padding-left: var(--space-lg); padding-right: var(--space-lg); }
|
||||
.lp-grid-5, .lp-grid-4, .lp-grid-3, .lp-app-grid { grid-template-columns: 1fr; }
|
||||
.lp-footer-grid { grid-template-columns: 1fr; }
|
||||
.lp-stats { flex-wrap: wrap; gap: var(--space-lg); }
|
||||
.lp-title { font-size: var(--text-2xl); }
|
||||
.lp-cc-content { max-width: 100%; }
|
||||
.lp-cc-visual { display: none; }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.carousel-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.carousel-card-visual {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.market-app-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
/* 尊重减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
@@ -4,39 +4,85 @@ 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>
|
||||
/* ---------- 内联 Lucide 风格图标(满足"用 Lucide 图标、禁 emoji"铁律) ---------- */
|
||||
const Icon: React.FC<{ d: React.ReactNode; size?: number }> = ({ d, size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
|
||||
/* ---------- 品牌同系渐变(仅靛蓝→青蓝,无第三色相) ---------- */
|
||||
const appGradients: Record<string, string> = {
|
||||
a1: 'linear-gradient(135deg,#5B5FED 0%,#4F8DF9 100%)',
|
||||
a2: 'linear-gradient(135deg,#6366F1 0%,#60A5FA 100%)',
|
||||
a3: 'linear-gradient(135deg,#5B5FED 0%,#818CF8 100%)',
|
||||
a4: 'linear-gradient(135deg,#4F8DF9 0%,#38BDF8 100%)',
|
||||
a5: 'linear-gradient(135deg,#6366F1 0%,#7DD3FC 100%)',
|
||||
a6: 'linear-gradient(135deg,#5B5FED 0%,#A78BFA 100%)',
|
||||
a7: 'linear-gradient(135deg,#4F8DF9 0%,#818CF8 100%)',
|
||||
a8: 'linear-gradient(135deg,#6366F1 0%,#4F8DF9 100%)',
|
||||
};
|
||||
const gradFor = (id: string) => appGradients[id] || 'linear-gradient(135deg,#5B5FED 0%,#4F8DF9 100%)';
|
||||
|
||||
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
|
||||
const typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
|
||||
const typeBadge: Record<string, string> = {
|
||||
skill: 'badge-skill', agent: 'badge-agent', mcp: 'badge-mcp',
|
||||
};
|
||||
|
||||
/* ---------- 文案数据(来自用户原始需求提炼) ---------- */
|
||||
const painPoints = [
|
||||
{ d: <><path d="M12 1v22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
|
||||
title: '大模型 API 贵且复杂', desc: '多家模型计费规则不一、调用门槛高,个人与小企业难以低成本接入。' },
|
||||
{ d: <><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></>,
|
||||
title: 'Prompt 难调试', desc: '缺乏可视化编排与复用,效果全靠手感,团队无法沉淀最佳实践。' },
|
||||
{ d: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5" /><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3" /></>,
|
||||
title: '数据孤岛', desc: '知识散落在文档、表格、聊天记录里,AI 答非所问,价值无法被激活。' },
|
||||
{ d: <><path d="M3 3v18h18" /><path d="m19 9-5 5-4-4-3 3" /></>,
|
||||
title: '个人开发者无变现渠道', desc: '做出好用的 AI 能力却找不到分发与交易场景,难以转化为收入。' },
|
||||
];
|
||||
|
||||
const valueProps = [
|
||||
{ d: <><path d="M12 1v22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
|
||||
title: '低价订阅 · 无限 Token', kw: '无限Token',
|
||||
desc: '一个低价会员即可放开使用,告别按次计费与额度焦虑,放心把 AI 用进日常。' },
|
||||
{ d: <><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" /></>,
|
||||
title: '垂直可用办公模板', kw: '办公提效',
|
||||
desc: '写 PPT、出投标书、做运营、排版公众号——开箱即用的模板,把重复劳动交给 AI。' },
|
||||
{ d: <><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" /><path d="M3 6h18" /><path d="M16 10a4 4 0 0 1-8 0" /></>,
|
||||
title: '可交易的技能市场', kw: 'AI变现',
|
||||
desc: '把自己打磨的技能上架交易,按订阅 / 下载获得收益,能力即资产。' },
|
||||
{ d: <><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" /></>,
|
||||
title: 'AI 创作试炼场', kw: '',
|
||||
desc: '写小说、做漫剧、谱音乐——低成本试错,让创作想法快速跑通。' },
|
||||
{ d: <><path d="M3 12a9 9 0 1 0 9-9" /><path d="M3 3v6h6" /><path d="M12 7v5l3 3" /></>,
|
||||
title: '低成本养龙虾', kw: '',
|
||||
desc: '低成本微调 / 托管你的专属模型,把"养模型"变成可负担的日常。' },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{ d: <><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></>,
|
||||
title: '多模型 AI 对话', desc: 'DeepSeek / Qwen / 国际模型自由切换,按需选用最优模型。' },
|
||||
{ d: <><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" /></>,
|
||||
title: '私有知识库', desc: '上传文档构建专属知识库,AI 回答基于你的内容,更精准可靠。' },
|
||||
{ d: <><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" /></>,
|
||||
title: '技能 / 应用市场', desc: '丰富的 Skills、Agent、MCP 应用,即装即用,一键接入工作流。' },
|
||||
{ d: <><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>,
|
||||
title: 'Token 驾驶舱', desc: '实时监控 Token 消耗,费用透明可控,每一笔都有据可查。' },
|
||||
{ d: <><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" /></>,
|
||||
title: 'IM 频道绑定', desc: '微信 / 企微 / 飞书 / QQ 多平台绑定,AI 助手融入日常工作流。' },
|
||||
{ d: <><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" /></>,
|
||||
title: '自动化任务编排', desc: '关键词触发、定时执行等自动化任务,让 AI 按你的节奏工作。' },
|
||||
];
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [mobileMenu, setMobileMenu] = 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 topSkills = [...publishedApps].sort((a, b) => b.installCount - a.installCount).slice(0, 5);
|
||||
|
||||
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);
|
||||
@@ -44,153 +90,160 @@ const LandingPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1 || isPaused) return;
|
||||
const timer = setInterval(nextSlide, 4000);
|
||||
const timer = setInterval(nextSlide, 4500);
|
||||
return () => clearInterval(timer);
|
||||
}, [banners.length, isPaused, nextSlide]);
|
||||
|
||||
const goToSlide = (idx: number) => setCurrentSlide(idx);
|
||||
// SEO / GEO:动态写入标题与描述
|
||||
useEffect(() => {
|
||||
document.title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式 PaaS 平台';
|
||||
let meta = document.querySelector('meta[name="description"]');
|
||||
if (!meta) { meta = document.createElement('meta'); meta.setAttribute('name', 'description'); document.head.appendChild(meta); }
|
||||
meta.setAttribute('content', '七虎AI 是面向个人与职场用户的 AI PaaS 平台:低价订阅无限 Token、垂直办公模板、可交易技能市场、AI 创作试炼场与低成本养模型,让每个人用 AI 把事做成。');
|
||||
}, []);
|
||||
|
||||
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%)',
|
||||
const goLogin = () => navigate('/login');
|
||||
|
||||
// 点击 logo 回到首页顶部(.lp 是滚动容器,用 scrollTo 而非 window)
|
||||
const goTop = () => {
|
||||
const el = document.querySelector('.lp') as HTMLElement | null;
|
||||
if (el) el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
|
||||
|
||||
// HashRouter 下原生 <a href="#id"> 会被当成路由路径解析,导致菜单失效;
|
||||
// 故改为编程式滚动 + preventDefault,仅在 LandingPage 内部滚动容器内跳转。
|
||||
const anchorClick = (id: string, after?: () => void) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
after?.();
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: '七虎AI',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
|
||||
aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.8', reviewCount: '1000' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
{/* Toast notification */}
|
||||
{toastMsg && (
|
||||
<div className="landing-toast">{toastMsg}</div>
|
||||
)}
|
||||
<div className="lp">
|
||||
{/* JSON-LD 富媒体摘要(SEO / GEO) */}
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
|
||||
{/* 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">
|
||||
{/* 浮层玻璃顶栏 */}
|
||||
<header className="lp-header glass">
|
||||
<div className="lp-header-inner">
|
||||
<div className="lp-brand" onClick={goTop} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && goTop()} aria-label="回到顶部">
|
||||
<svg width="30" height="30" viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||
<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"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<path d="M6 22h20" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.5" />
|
||||
</svg>
|
||||
<span className="brand-name">Qihu-AIFlow</span>
|
||||
<span className="brand-name">七虎AI</span>
|
||||
</div>
|
||||
<nav className="landing-nav-links">
|
||||
<a href="#market">应用市场</a>
|
||||
<a href="#features">平台能力</a>
|
||||
|
||||
<nav className="lp-nav" aria-label="主导航">
|
||||
<a href="#positioning" onClick={anchorClick('positioning')}>平台定位</a>
|
||||
<a href="#value" onClick={anchorClick('value')}>平台价值</a>
|
||||
<a href="#top" onClick={anchorClick('top')}>热门技能</a>
|
||||
<a href="#market" onClick={anchorClick('market')}>应用市场</a>
|
||||
<a href="#features" onClick={anchorClick('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 className="lp-header-actions">
|
||||
<button className="lp-btn lp-btn-ghost" onClick={goLogin}>登录</button>
|
||||
<button className="lp-btn lp-btn-primary" onClick={goLogin}>免费注册</button>
|
||||
</div>
|
||||
|
||||
<button className="lp-menu-toggle" aria-label="菜单" onClick={() => setMobileMenu(v => !v)}>
|
||||
<Icon d={mobileMenu ? <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></> : <><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></>} size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileMenu && (
|
||||
<div className="lp-mobile-menu">
|
||||
<a href="#positioning" onClick={anchorClick('positioning', () => setMobileMenu(false))}>平台定位</a>
|
||||
<a href="#value" onClick={anchorClick('value', () => setMobileMenu(false))}>平台价值</a>
|
||||
<a href="#top" onClick={anchorClick('top', () => setMobileMenu(false))}>热门技能</a>
|
||||
<a href="#market" onClick={anchorClick('market', () => setMobileMenu(false))}>应用市场</a>
|
||||
<a href="#features" onClick={anchorClick('features', () => setMobileMenu(false))}>平台能力</a>
|
||||
<div className="lp-mobile-actions">
|
||||
<button className="lp-btn lp-btn-ghost" onClick={() => { setMobileMenu(false); goLogin(); }}>登录</button>
|
||||
<button className="lp-btn lp-btn-primary" onClick={() => { setMobileMenu(false); goLogin(); }}>免费注册</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。
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="lp-hero">
|
||||
<div className="lp-hero-grid">
|
||||
<div className="lp-hero-text">
|
||||
<div className="lp-eyebrow">AI PaaS 平台</div>
|
||||
<h1 className="lp-title">让每个人都能<br /><span className="lp-accent">用 AI 把事做成</span></h1>
|
||||
<p className="lp-desc">
|
||||
七虎AI 是个人体验 AI、办公提效、AI 创收与自我提升的一站式交互平台。
|
||||
低价订阅 <span className="kw">无限Token</span>,提供垂直可用的办公模板、可交易的市场与低成本养模型能力。
|
||||
</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 className="lp-actions">
|
||||
<button className="lp-btn lp-btn-primary lp-btn-lg" onClick={goLogin}>免费开始使用</button>
|
||||
<button className="lp-btn lp-btn-outline lp-btn-lg" onClick={anchorClick('market')}>浏览应用市场</button>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">3,500+</span>
|
||||
<span className="stat-label">活跃用户</span>
|
||||
<div className="lp-stats">
|
||||
<div><span className="lp-stat-num">3,500+</span><span className="lp-stat-label">活跃用户</span></div>
|
||||
<div><span className="lp-stat-num">120+</span><span className="lp-stat-label">应用 / Skills</span></div>
|
||||
<div><span className="lp-stat-num">99.8%</span><span className="lp-stat-label">服务可用率</span></div>
|
||||
</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>
|
||||
|
||||
{/* Hero 右侧:品牌渐变光斑 + 玻璃卡视觉 */}
|
||||
<div className="lp-hero-visual" aria-hidden>
|
||||
<div className="lp-hero-glow" />
|
||||
<div className="lp-hero-card glass">
|
||||
<div className="lp-hc-row"><span className="lp-hc-dot" /> AI 对话进行中</div>
|
||||
<div className="lp-hc-bubble">帮我把这份文档整理成周报大纲</div>
|
||||
<div className="lp-hc-bubble lp-hc-bubble-ai">已生成 5 个章节,并附数据看板建议 ✓</div>
|
||||
<div className="lp-hc-bar"><span /></div>
|
||||
</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>
|
||||
<section className="lp-band">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">精选推荐</h2>
|
||||
<p className="lp-sub">平台热门技能广告位,提升展现与下载</p>
|
||||
</div>
|
||||
<div className="carousel-card-visual">
|
||||
<div className="visual-circle c1"/>
|
||||
<div className="visual-circle c2"/>
|
||||
<div className="visual-circle c3"/>
|
||||
<div className="lp-carousel" onMouseEnter={() => setIsPaused(true)} onMouseLeave={() => setIsPaused(false)}>
|
||||
<div className="lp-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
||||
{banners.map(b => (
|
||||
<div key={b.id} className="lp-slide">
|
||||
<div className="lp-carousel-card" style={{ background: gradFor(b.linkAppId || 'a1') }}>
|
||||
<div className="lp-cc-content">
|
||||
<div className="lp-cc-badge">推荐</div>
|
||||
<h3 className="lp-cc-title">{b.title}</h3>
|
||||
{b.linkAppName && <p className="lp-cc-sub">立即体验 {b.linkAppName}</p>}
|
||||
<button className="lp-cc-cta" onClick={goLogin}>查看详情 <Icon d={<polyline points="9 18 15 12 9 6" />} size={14} /></button>
|
||||
</div>
|
||||
<div className="lp-cc-visual"><span className="c1" /><span className="c2" /><span className="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>
|
||||
<button className="lp-arrow lp-arrow-l" onClick={() => setCurrentSlide(p => (p - 1 + banners.length) % banners.length)} aria-label="上一张"><Icon d={<polyline points="15 18 9 12 15 6" />} size={20} /></button>
|
||||
<button className="lp-arrow lp-arrow-r" onClick={nextSlide} aria-label="下一张"><Icon d={<polyline points="9 18 15 12 9 6" />} size={20} /></button>
|
||||
<div className="lp-dots">{banners.map((_, i) => (<button key={i} className={`lp-dot ${i === currentSlide ? 'on' : ''}`} onClick={() => setCurrentSlide(i)} aria-label={`第${i + 1}张`} />))}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -198,169 +251,154 @@ const LandingPage: React.FC = () => {
|
||||
</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>
|
||||
{/* 平台定位(痛点) */}
|
||||
<section className="lp-section" id="positioning">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">我们解决什么</h2>
|
||||
<p className="lp-sub">七虎AI 帮你绕过个人与职场使用 AI 时最高频的痛点</p>
|
||||
</div>
|
||||
<div className="lp-grid lp-grid-4">
|
||||
{painPoints.map((p, i) => (
|
||||
<article key={i} className="lp-pain">
|
||||
<div className="lp-pain-icon"><Icon d={p.d} size={24} /></div>
|
||||
<h3 className="lp-pain-title">{p.title}</h3>
|
||||
<p className="lp-pain-desc">{p.desc}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{/* 平台价值 */}
|
||||
<section className="lp-section lp-section-alt" id="value">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">在七虎AI 你能获得</h2>
|
||||
<p className="lp-sub">降低订阅成本、提供无限 Token、垂直模板与可交易市场</p>
|
||||
</div>
|
||||
<div className="lp-grid lp-grid-5">
|
||||
{valueProps.map((v, i) => (
|
||||
<article key={i} className="lp-value">
|
||||
<div className="lp-value-icon"><Icon d={v.d} size={24} /></div>
|
||||
<h3 className="lp-value-title">{v.kw ? <>{v.title.replace(v.kw, '')}<span className="kw"> {v.kw}</span></> : v.title}</h3>
|
||||
<p className="lp-value-desc">{v.desc}</p>
|
||||
</article>
|
||||
))}
|
||||
</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>
|
||||
{/* 热门技能榜 Top5 */}
|
||||
<section className="lp-section" id="top">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">热门技能榜 · Top 5</h2>
|
||||
<p className="lp-sub">按下载 / 安装量排行,看看大家都在用哪些能力</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>
|
||||
<ol className="lp-rank">
|
||||
{topSkills.map((app, i) => (
|
||||
<li key={app.id} className="lp-rank-item" onClick={goLogin} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && goLogin()}>
|
||||
<span className={`lp-rank-no ${i === 0 ? 'top' : ''}`}>{i + 1}</span>
|
||||
<span className="lp-rank-cover" style={{ background: gradFor(app.id) }}>
|
||||
<span className={`lp-rank-badge ${typeBadge[app.type]}`}>{typeLabels[app.type]}</span>
|
||||
</span>
|
||||
<span className="lp-rank-main">
|
||||
<span className="lp-rank-name">{app.name}</span>
|
||||
<span className="lp-rank-desc">{app.description}</span>
|
||||
</span>
|
||||
<span className="lp-rank-meta">
|
||||
<span className="lp-rank-installs"><Icon d={<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" />} size={13} /> {app.installCount.toLocaleString()}</span>
|
||||
<span className="lp-rank-rating">{(app.ratingAvg > 0 ? app.ratingAvg : '—')}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<h3 className="feature-title">多模型AI对话</h3>
|
||||
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
|
||||
</section>
|
||||
|
||||
{/* 应用市场预览 */}
|
||||
<section className="lp-section lp-section-alt" id="market">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">应用市场</h2>
|
||||
<p className="lp-sub">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</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 className="lp-app-grid">
|
||||
{publishedApps.map(app => (
|
||||
<article key={app.id} className="lp-app-card" onClick={goLogin} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && goLogin()}>
|
||||
<div className="lp-app-cover" style={{ background: gradFor(app.id) }}>
|
||||
<span className={`lp-app-badge ${typeBadge[app.type]}`}>{typeLabels[app.type]}</span>
|
||||
{app.price > 0 && <span className="lp-app-price">{app.price} 元</span>}
|
||||
</div>
|
||||
<h3 className="feature-title">私有知识库</h3>
|
||||
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
|
||||
<div className="lp-app-body">
|
||||
<h4 className="lp-app-name">{app.name}</h4>
|
||||
<p className="lp-app-desc">{app.description}</p>
|
||||
<div className="lp-app-meta">
|
||||
<span><Icon d={<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" />} size={12} /> {app.ratingAvg > 0 ? app.ratingAvg : '-'}</span>
|
||||
<span>{app.installCount.toLocaleString()} 次安装</span>
|
||||
</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>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="lp-more">
|
||||
<button className="lp-btn lp-btn-outline" onClick={goLogin}>登录查看更多应用 <Icon d={<polyline points="9 18 15 12 9 6" />} size={14} /></button>
|
||||
</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>
|
||||
{/* 平台能力 */}
|
||||
<section className="lp-section" id="features">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-section-head">
|
||||
<h2 className="lp-h2">平台核心能力</h2>
|
||||
<p className="lp-sub">从对话到自动化,七虎AI 提供完整的 AI 工作流解决方案</p>
|
||||
</div>
|
||||
<div className="lp-grid lp-grid-3">
|
||||
{features.map((f, i) => (
|
||||
<article key={i} className="lp-feature">
|
||||
<div className="lp-feature-icon"><Icon d={f.d} size={26} /></div>
|
||||
<h3 className="lp-feature-title">{f.title}</h3>
|
||||
<p className="lp-feature-desc">{f.desc}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
{/* CTA */}
|
||||
<section className="lp-cta">
|
||||
<div className="lp-inner">
|
||||
<h2 className="lp-cta-title">准备好开始了吗?</h2>
|
||||
<p className="lp-cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
|
||||
<button className="lp-btn lp-btn-primary lp-btn-lg" onClick={goLogin}>免费注册,立即体验</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer(SEO 链接 + 富媒体) */}
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-footer-grid">
|
||||
<div className="lp-footer-brand">
|
||||
<div className="lp-brand" onClick={goTop} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && goTop()} aria-label="回到顶部">
|
||||
<svg width="26" height="26" viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||
<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="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
|
||||
<span className="brand-name">七虎AI</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
|
||||
AI智能自动化PaaS平台
|
||||
</p>
|
||||
<p>个人体验 AI、办公提效与 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 className="lp-footer-col"><h4>产品</h4><a href="#features" onClick={anchorClick('features')}>AI 聊天</a><a href="#market" onClick={anchorClick('market')}>应用市场</a><a href="#value" onClick={anchorClick('value')}>技能交易</a></div>
|
||||
<div className="lp-footer-col"><h4>资源</h4><a href="#top" onClick={anchorClick('top')}>热门技能</a><a href="#positioning" onClick={anchorClick('positioning')}>平台定位</a><a onClick={goLogin} role="button">帮助中心</a></div>
|
||||
<div className="lp-footer-col"><h4>公司</h4><a onClick={goLogin} role="button">关于我们</a><a onClick={goLogin} role="button">联系我们</a><a onClick={goLogin} role="button">加入我们</a></div>
|
||||
</div>
|
||||
<div className="lp-footer-bottom">© 2026 七虎AI · 让每个人都能用 AI 把事做成</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -983,6 +983,37 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 导入定价字段 */
|
||||
.hub-price-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin: var(--space-md) 0;
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-subtle, #F8F9FC);
|
||||
border: 1px solid var(--color-border, #E5E7EB);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.hub-price-input {
|
||||
max-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border, #D1D5DB);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text, #1A1F36);
|
||||
}
|
||||
.hub-price-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6C63FF);
|
||||
box-shadow: 0 0 0 2px rgba(108, 99, 255, 0.15);
|
||||
}
|
||||
.hub-price-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted, #6B7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hub-platforms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -193,6 +193,8 @@ const skillsHubPlatforms = [
|
||||
|
||||
const mcpHubPlatforms = [
|
||||
{ id: 'modelscope-mcp', name: 'ModelScope MCP', url: 'https://modelscope.cn/mcp' },
|
||||
{ id: 'mcp.so', name: 'MCP.so', url: 'https://mcp.so' },
|
||||
{ id: 'mcpmarket', name: 'MCPMarket', url: 'https://mcpmarket.cn' },
|
||||
];
|
||||
|
||||
// ===== Agent External Platforms (修改4) =====
|
||||
@@ -580,7 +582,7 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
||||
<p>系统将为你在AI聊天中创建一个临时新对话窗口。接下来的所有对话内容都将转化为该Skills工具的核心定义——包括功能描述、使用方式、参数配置和输出格式。</p>
|
||||
</div>
|
||||
<button className="form-submit-btn" onClick={() => {
|
||||
window.location.hash = '#/chat';
|
||||
window.location.href = '/chat';
|
||||
onClose();
|
||||
}}>
|
||||
前往AI聊天
|
||||
@@ -868,12 +870,13 @@ interface HubImportModalProps {
|
||||
appType: 'skill' | 'mcp';
|
||||
platforms: typeof skillsHubPlatforms;
|
||||
onClose: () => void;
|
||||
onImport: (platformName: string, url: string, skillName: string) => void;
|
||||
onImport: (platformName: string, url: string, skillName: string, price?: number) => void;
|
||||
}
|
||||
|
||||
const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onClose, onImport }) => {
|
||||
const [importUrl, setImportUrl] = useState<Record<string, string>>({});
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
const [importPrice, setImportPrice] = useState<number>(0);
|
||||
const [importStatus, setImportStatus] = useState<'idle' | 'verifying' | 'checking_format' | 'loading' | 'deploying' | 'published' | 'error'>('idle');
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
|
||||
@@ -927,7 +930,7 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
||||
setStatusMsg(step.msg);
|
||||
if (step.status === 'published') {
|
||||
setTimeout(() => {
|
||||
onImport(platformId, appType === 'mcp' ? hubCode : url, extractSkillName(url, platformId));
|
||||
onImport(platformId, appType === 'mcp' ? hubCode : url, extractSkillName(url, platformId), importPrice);
|
||||
setImportingId(null);
|
||||
setImportStatus('idle');
|
||||
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
|
||||
@@ -955,7 +958,21 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
||||
<div className="hub-step-arrow"/>
|
||||
<div className="hub-step"><span className="hub-step-num">3</span> {appType === 'skill' ? '粘贴链接至输入框并点击导入' : '粘贴代码到编辑器,通过AI验证后点击导入'}</div>
|
||||
</div>
|
||||
{appType === 'skill' && <div className="hub-free-note">通过以下平台导入的Skills默认为免费。</div>}
|
||||
<div className="hub-price-section">
|
||||
<label className="form-label">定价(元)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="hub-price-input"
|
||||
value={importPrice}
|
||||
onChange={e => setImportPrice(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="hub-price-hint">
|
||||
用户导入的{appType === 'skill' ? 'Skills' : 'MCP'}:是否收费由您决定(0 为免费)。管理员导入默认免费且审核通过。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* MCP Hub Import: Code Editor + AI验证 */}
|
||||
{appType === 'mcp' && (
|
||||
@@ -1093,13 +1110,14 @@ const mcpClient = new MCPClient({
|
||||
// ===== Agent Platform Connect Modal (修改4) =====
|
||||
interface AgentConnectModalProps {
|
||||
onClose: () => void;
|
||||
onConnect: (platformId: string, config: Record<string, string>) => void;
|
||||
onConnect: (platformId: string, config: Record<string, string>, price?: number) => void;
|
||||
}
|
||||
|
||||
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [price, setPrice] = useState<number>(0);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connected, setConnected] = useState<string | null>(null);
|
||||
|
||||
@@ -1109,7 +1127,7 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
||||
setTimeout(() => {
|
||||
setConnecting(false);
|
||||
setConnected(selectedPlatform);
|
||||
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() });
|
||||
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
@@ -1177,6 +1195,19 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
||||
onChange={e => setEndpoint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">定价(元)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
placeholder="0 为免费"
|
||||
value={price}
|
||||
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<span className="hub-price-hint">用户对接的Agent:是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||
</div>
|
||||
<button
|
||||
className="form-submit-btn"
|
||||
onClick={handleConnect}
|
||||
@@ -1286,14 +1317,14 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Handle hub import - 调用后端 API 创建应用
|
||||
const handleHubImport = async (platformName: string, url: string, skillName: string) => {
|
||||
const handleHubImport = async (platformName: string, url: string, skillName: string, price?: number) => {
|
||||
try {
|
||||
await marketAPI.createApp({
|
||||
name: skillName,
|
||||
type: showHubImport || 'skill',
|
||||
description: `从 ${platformName} 导入的${showHubImport === 'skill' ? 'Skills工具' : 'MCP应用'} (来源: ${url})`,
|
||||
price: 0,
|
||||
is_member_free: true,
|
||||
price: price || 0,
|
||||
is_member_free: (price || 0) === 0,
|
||||
join_promotion: false,
|
||||
source: 'user',
|
||||
});
|
||||
@@ -1306,7 +1337,7 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Handle agent connect - 调用后端 API 创建应用
|
||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>) => {
|
||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number) => {
|
||||
const platform = agentPlatforms.find(p => p.id === platformId);
|
||||
if (!platform) return;
|
||||
try {
|
||||
@@ -1314,8 +1345,8 @@ const MarketPage: React.FC = () => {
|
||||
name: `${platform.name} Agent`,
|
||||
type: 'agent',
|
||||
description: `从 ${platform.name} 对接导入的智能Agent`,
|
||||
price: 0,
|
||||
is_member_free: true,
|
||||
price: price || 0,
|
||||
is_member_free: (price || 0) === 0,
|
||||
join_promotion: false,
|
||||
source: 'user',
|
||||
agent_endpoint: config.endpoint || '',
|
||||
|
||||
@@ -209,7 +209,7 @@ const PricingPage: React.FC = () => {
|
||||
setShowModal(null);
|
||||
// 跳转到聊天页面,带定制服务ID
|
||||
setTimeout(() => {
|
||||
window.location.hash = '#/chat?service_id=' + res.data.id;
|
||||
window.location.href = '/chat?service_id=' + res.data.id;
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -678,8 +678,8 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
|
||||
</div>
|
||||
|
||||
<div className="membership-actions">
|
||||
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
|
||||
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
|
||||
<button className="btn-primary" onClick={() => { window.location.href = '/pricing'; }}>续费会员</button>
|
||||
<button className="btn-secondary" onClick={() => { window.location.href = '/pricing'; }}>升级会员</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -692,7 +692,7 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.hash = '#/pricing';
|
||||
window.location.href = '/pricing';
|
||||
}}
|
||||
>
|
||||
开通会员
|
||||
|
||||
Reference in new issue
Block a user