feat(frontend): HashRouter->BrowserRouter 干净URL可独立索引 + 静态预渲染(SSR注入#root/robots/sitemap) + logo回顶
This commit is contained in:
1 parent
a1bea39d9f
commit
d404aa4e29
12 files changed
+1390
-1138
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过期/未登录,清除状态并跳转
|
// 其他接口的401 = token过期/未登录,清除状态并跳转
|
||||||
localStorage.removeItem('aiflow_token');
|
localStorage.removeItem('aiflow_token');
|
||||||
localStorage.removeItem('aiflow_user');
|
localStorage.removeItem('aiflow_user');
|
||||||
window.location.hash = '#/login';
|
window.location.href = '/login';
|
||||||
throw new Error('未登录');
|
throw new Error('未登录');
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -105,12 +105,12 @@ export const chatAPI = {
|
|||||||
del<any>(`/chat/conversations/${id}`),
|
del<any>(`/chat/conversations/${id}`),
|
||||||
getMessages: (convId: string) =>
|
getMessages: (convId: string) =>
|
||||||
get<any[]>(`/chat/conversations/${convId}/messages`),
|
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),
|
post<any>(`/chat/conversations/${convId}/messages`, data),
|
||||||
clearContext: (convId: string) =>
|
clearContext: (convId: string) =>
|
||||||
post<any>(`/chat/conversations/${convId}/clear`),
|
post<any>(`/chat/conversations/${convId}/clear`),
|
||||||
convertToKnowledgeGraph: (convId: string) =>
|
convertToKnowledgeGraph: (convId: string) =>
|
||||||
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
|
post<any>(`/chat/conversations/${convId}/to-knowledge-graph/`),
|
||||||
getModels: () => get<any[]>('/chat/models'),
|
getModels: () => get<any[]>('/chat/models'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
|
|||||||
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
|
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
|
||||||
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
|
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
|
||||||
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
|
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}`),
|
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
|
||||||
getDirectories: () => get<any[]>('/knowledge/directories'),
|
getDirectories: () => get<any[]>('/knowledge/directories'),
|
||||||
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
|
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),
|
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
|
||||||
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
|
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||||
getModelStats: () => get<any>('/admin/models/stats'),
|
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'),
|
getSiteSettings: () => get<any>('/admin/settings/site'),
|
||||||
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
|
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`),
|
getTenantMembers: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/members`),
|
||||||
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
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 =====
|
// ===== AI自进化API =====
|
||||||
export const evolutionAPI = {
|
export const evolutionAPI = {
|
||||||
// 驾驶舱面板
|
// 驾驶舱面板
|
||||||
@@ -803,9 +789,212 @@ export const evolutionAPI = {
|
|||||||
return get<import('@/types').EvolutionReport[]>(`/evolution/reports${qs}`);
|
return get<import('@/types').EvolutionReport[]>(`/evolution/reports${qs}`);
|
||||||
},
|
},
|
||||||
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
|
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 = {
|
export const imUserAPI = {
|
||||||
getStatus: () => get<any>('/im/wechat/status'),
|
getStatus: () => get<any>('/im/wechat/status'),
|
||||||
qrcode: () => post<any>('/im/wechat/qrcode'),
|
qrcode: () => post<any>('/im/wechat/qrcode'),
|
||||||
|
|||||||
@@ -1,121 +1,233 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import './AdminLayout.css';
|
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', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
||||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
{ path: '/admin/analytics', icon: 'analytics', 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: '增值服务管理' },
|
title: 'AI 能力',
|
||||||
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
|
icon: 'agents',
|
||||||
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
|
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/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/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> = {
|
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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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="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>,
|
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 AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const location = useLocation();
|
||||||
|
|
||||||
if (!user || user.role !== 'admin') {
|
// Default: all groups collapsed. Auto-expand the group containing the active route.
|
||||||
navigate('/login');
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="admin-layout">
|
<div className="admin-layout">
|
||||||
{/* Admin Sidebar */}
|
<aside className="admin-sidebar">
|
||||||
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
|
||||||
<div className="admin-sidebar-header">
|
<div className="admin-sidebar-header">
|
||||||
<div className="sidebar-logo">
|
<h1 className="admin-logo">七虎AI</h1>
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
<span className="admin-subtitle">管理控制台</span>
|
||||||
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
|
|
||||||
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
|
||||||
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
|
||||||
</svg>
|
|
||||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
<button onClick={handleVisitFrontend} className="admin-frontend-link">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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>
|
||||||
<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"/>
|
<span>访问前台</span>
|
||||||
</svg>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
<nav className="admin-nav">
|
<div className="nav-group-items">
|
||||||
{adminNavItems.map(item => (
|
{group.items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
end={item.exact}
|
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>
|
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
|
||||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
<span className="nav-item-label">{item.label}</span>
|
||||||
</NavLink>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
</aside>
|
||||||
|
<main className="admin-content">
|
||||||
{/* Admin Main Content */}
|
|
||||||
<main className="admin-main">
|
|
||||||
<header className="admin-topbar">
|
|
||||||
<div className="admin-topbar-left">
|
|
||||||
<span className="admin-welcome">欢迎,{user.username}</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-topbar-right">
|
|
||||||
<a href="#/chat" className="admin-topbar-action">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
||||||
<span>返回前台</span>
|
|
||||||
</a>
|
|
||||||
<div className="admin-user-tag">
|
|
||||||
<div className="user-avatar-sm admin-avatar">
|
|
||||||
{user.username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span>{user.username}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="admin-content">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<HashRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</HashRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</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 { useNavigate } from 'react-router-dom';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
@@ -8,6 +8,28 @@ const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ child
|
|||||||
</svg>
|
</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 = [
|
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/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||||
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
|
{ path: '/admin/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> },
|
{ 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 Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
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 [operationLogs, setOperationLogs] = useState<any[]>([]);
|
||||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -29,14 +128,27 @@ const Dashboard: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, logsRes, auditRes] = await Promise.all([
|
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
|
||||||
adminAPI.getStats(),
|
adminAPI.getStats() as any,
|
||||||
adminAPI.getRecentLogs(),
|
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
|
||||||
adminAPI.getRecentAudit(),
|
adminAPI.getRecentLogs() as any,
|
||||||
|
adminAPI.getRecentAudit() as any,
|
||||||
]);
|
]);
|
||||||
if (statsRes.data) setStats(statsRes.data);
|
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
|
||||||
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
const statsData = statsRes?.data || statsRes;
|
||||||
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
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) {
|
} catch (err) {
|
||||||
console.error('Dashboard数据加载失败:', err);
|
console.error('Dashboard数据加载失败:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,15 +166,24 @@ const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatTokens = (n: number): string => {
|
const formatTokens = (n: number): string => {
|
||||||
|
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
|
||||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||||
return n.toLocaleString('en-US');
|
return n.toLocaleString('en-US');
|
||||||
};
|
};
|
||||||
|
|
||||||
const statCards = [
|
const statCardDefs = [
|
||||||
{ 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.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
|
||||||
{ 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> },
|
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.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: '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)',
|
||||||
{ 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> },
|
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) {
|
if (loading) {
|
||||||
@@ -78,85 +199,121 @@ const Dashboard: React.FC = () => {
|
|||||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||||
<h1 className="admin-page-title">平台驾驶舱</h1>
|
<h1 className="admin-page-title">平台驾驶舱</h1>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards Row */}
|
||||||
<div className="admin-stats">
|
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
{statCards.map((s, i) => (
|
{statCardDefs.map((s, i) => (
|
||||||
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
|
<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={{
|
<div style={{
|
||||||
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
|
width: 48, height: 48, borderRadius: 14,
|
||||||
background: s.bg, color: s.color,
|
background: s.bg, color: s.color,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{s.icon}
|
{s.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="admin-stat-label">{s.label}</div>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Cumulative Token Stats */}
|
{/* Charts Row */}
|
||||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
<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">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||||
{' '}平台累计Token消耗
|
{' '}Token消耗趋势(近30天)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
|
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
||||||
{[
|
<BarChart data={dailyStats} />
|
||||||
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
|
|
||||||
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
|
|
||||||
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
|
|
||||||
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
|
|
||||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Entry Cards */}
|
{/* System Health */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
|
<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) => (
|
{quickEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
style={{
|
style={{
|
||||||
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
|
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
|
||||||
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
|
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
|
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-accent)';
|
e.currentTarget.style.borderColor = '#6C63FF';
|
||||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
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={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
|
||||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
|
<div>
|
||||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent Operation Logs */}
|
{/* Recent Logs */}
|
||||||
<div className="admin-card">
|
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">最近操作日志</h3>
|
<h3 className="admin-card-title">最近操作日志</h3>
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
@@ -192,6 +349,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">最近审计日志</h3>
|
<h3 className="admin-card-title">最近审计日志</h3>
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
|
|||||||
@@ -4,40 +4,86 @@ import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
|
|||||||
import type { Conversation, Message } from '@/types';
|
import type { Conversation, Message } from '@/types';
|
||||||
import './ChatPage.css';
|
import './ChatPage.css';
|
||||||
|
|
||||||
// ===== Simple Markdown Renderer =====
|
// ===== Enhanced Markdown Renderer =====
|
||||||
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
let text = content;
|
let text = content;
|
||||||
// Code blocks (```)
|
|
||||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => {
|
// 1. Extract code blocks first (protect from other replacements)
|
||||||
return `<pre class="md-code-block"><code class="md-code">${escapeHtml(code.trim())}</code></pre>`;
|
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>');
|
// 2. Extract inline code
|
||||||
// Bold (**text**)
|
const inlineCodes: string[] = [];
|
||||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
|
||||||
// Italic (*text*)
|
const idx = inlineCodes.length;
|
||||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
|
||||||
// Headers (## heading)
|
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, '<h4 class="md-h4">$1</h4>');
|
||||||
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
|
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
|
||||||
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
|
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
|
||||||
// Unordered list items
|
|
||||||
text = text.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>');
|
// 4. Horizontal rule
|
||||||
// Ordered list items
|
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
|
||||||
text = text.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
|
|
||||||
// Horizontal rule
|
// 5. Blockquotes
|
||||||
text = text.replace(/^---$/gm, '<hr class="md-hr">');
|
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
|
||||||
// Paragraph spacing: double newlines
|
|
||||||
text = text.replace(/\n\n/g, '<br/><br/>');
|
// 6. Links [text](url)
|
||||||
// Single newlines to <br>
|
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/>');
|
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;
|
return text;
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -387,16 +433,16 @@ const ChatPage: React.FC = () => {
|
|||||||
let apps: any[] = [];
|
let apps: any[] = [];
|
||||||
if (Array.isArray(data)) apps = data;
|
if (Array.isArray(data)) apps = data;
|
||||||
else if (data?.items) apps = data.items;
|
else if (data?.items) apps = data.items;
|
||||||
// 过滤已卸载和未安装的agent
|
// 显示所有已发布的Agent(不需要安装即可在聊天中使用)
|
||||||
setAgents(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
|
setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
marketAPI.getApps({ type: 'skill' }).then((res: any) => {
|
marketAPI.getApps({ type: 'skill' }).then((res: any) => {
|
||||||
const data = res?.data !== undefined ? res.data : res;
|
const data = res?.data !== undefined ? res.data : res;
|
||||||
let apps: any[] = [];
|
let apps: any[] = [];
|
||||||
if (Array.isArray(data)) apps = data;
|
if (Array.isArray(data)) apps = data;
|
||||||
else if (data?.items) apps = data.items;
|
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(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -547,8 +593,10 @@ const ChatPage: React.FC = () => {
|
|||||||
kbName: res.data.knowledge_base_name,
|
kbName: res.data.knowledge_base_name,
|
||||||
kbId: res.data.knowledge_base_id,
|
kbId: res.data.knowledge_base_id,
|
||||||
});
|
});
|
||||||
|
// 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
|
||||||
|
const newCount = res.data.knowledge_graph_count ?? 1;
|
||||||
setConversations(prev =>
|
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 {
|
} else {
|
||||||
setKgError(res.message || '转换失败');
|
setKgError(res.message || '转换失败');
|
||||||
@@ -582,7 +630,7 @@ const ChatPage: React.FC = () => {
|
|||||||
model_id: selectedModelId || undefined,
|
model_id: selectedModelId || undefined,
|
||||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : 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,
|
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -753,6 +801,13 @@ const ChatPage: React.FC = () => {
|
|||||||
<div className="model-dropdown-item">
|
<div className="model-dropdown-item">
|
||||||
<span>{model.name}</span>
|
<span>{model.name}</span>
|
||||||
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -797,7 +852,7 @@ const ChatPage: React.FC = () => {
|
|||||||
<span
|
<span
|
||||||
key={skillName}
|
key={skillName}
|
||||||
className="evo-skill-tag"
|
className="evo-skill-tag"
|
||||||
onClick={() => window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`}
|
onClick={() => { window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
|
||||||
title={`使用了专属技能:${skillName}`}
|
title={`使用了专属技能:${skillName}`}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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 {
|
/* 玻璃拟态工具类(DESIGN.md §4) */
|
||||||
position: fixed;
|
.glass {
|
||||||
top: 0;
|
background: rgba(255, 255, 255, 0.65);
|
||||||
left: 0;
|
backdrop-filter: blur(16px) saturate(140%);
|
||||||
right: 0;
|
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||||
bottom: 0;
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
overflow-y: auto;
|
box-shadow: var(--shadow-md);
|
||||||
overflow-x: hidden;
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
z-index: var(--z-base);
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast notification */
|
/* ===== 顶栏 ===== */
|
||||||
.landing-toast {
|
.lp-header {
|
||||||
position: fixed;
|
position: sticky; top: 0; z-index: var(--z-sticky);
|
||||||
top: 76px;
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
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-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 {
|
/* 按钮(DESIGN.md §4) */
|
||||||
max-width: 1120px;
|
.lp-btn {
|
||||||
margin: 0 auto;
|
font-family: var(--font-sans); font-size: var(--text-base); font-weight: 600;
|
||||||
padding: 0 var(--space-2xl);
|
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 ===== */
|
.lp-menu-toggle { display: none; background: transparent; border: none; color: var(--color-text-primary); cursor: pointer; margin-left: auto; }
|
||||||
.landing-header {
|
.lp-mobile-menu {
|
||||||
position: sticky;
|
display: flex; flex-direction: column; gap: var(--space-xs);
|
||||||
top: 0;
|
padding: var(--space-md) var(--space-2xl) var(--space-2xl);
|
||||||
z-index: 100;
|
border-top: 1px solid var(--color-border-light); background: var(--color-bg-secondary);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
.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;
|
.lp-inner { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl); }
|
||||||
margin: 0 auto;
|
.lp-section { padding: var(--space-5xl) 0; }
|
||||||
padding: 0 var(--space-2xl);
|
/* 锚点跳转时避开 sticky 顶栏遮挡 */
|
||||||
height: 60px;
|
.lp-section[id] { scroll-margin-top: 76px; }
|
||||||
display: flex;
|
.lp-section-alt { background: var(--color-bg-tertiary); }
|
||||||
align-items: center;
|
.lp-section-head { text-align: center; max-width: 720px; margin: 0 auto var(--space-4xl); }
|
||||||
gap: var(--space-2xl);
|
.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); }
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Hero ===== */
|
/* ===== Hero ===== */
|
||||||
.landing-hero {
|
.lp-hero {
|
||||||
padding: 80px 0 72px;
|
background: linear-gradient(180deg, #EEEDFF 0%, #F8F9FB 42%);
|
||||||
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
|
padding: var(--space-5xl) 0 var(--space-4xl);
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
}
|
||||||
|
.lp-hero-grid {
|
||||||
.carousel-track {
|
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
|
||||||
display: flex;
|
display: grid; grid-template-columns: 1.05fr .95fr; gap: var(--space-4xl); align-items: center;
|
||||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
.lp-eyebrow {
|
||||||
.carousel-slide {
|
display: inline-block; font-size: var(--text-sm); font-weight: 600; color: var(--color-accent);
|
||||||
min-width: 100%;
|
background: var(--color-accent-light); padding: 4px 12px; border-radius: var(--radius-full); margin-bottom: var(--space-lg);
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.lp-hero-visual { position: relative; min-height: 320px; display: flex; align-items: center; justify-content: center; }
|
||||||
display: flex;
|
.lp-hero-glow {
|
||||||
align-items: center;
|
position: absolute; width: 320px; height: 320px; border-radius: 50%;
|
||||||
justify-content: space-between;
|
background: radial-gradient(circle, rgba(91,95,237,.22), transparent 70%);
|
||||||
padding: var(--space-3xl) var(--space-3xl);
|
filter: blur(8px);
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
min-height: 200px;
|
|
||||||
color: #FFFFFF;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
.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;
|
.lp-band { padding: var(--space-4xl) 0; }
|
||||||
}
|
.lp-carousel { position: relative; }
|
||||||
|
.lp-track { display: flex; transition: transform var(--transition-slow); }
|
||||||
.carousel-badge {
|
.lp-slide { min-width: 100%; padding: 0 var(--space-xs); }
|
||||||
display: inline-block;
|
.lp-carousel-card {
|
||||||
padding: 4px 12px;
|
border-radius: var(--radius-xl); padding: var(--space-4xl); color: #fff; position: relative; overflow: hidden;
|
||||||
background: rgba(255,255,255,0.25);
|
display: flex; justify-content: space-between; align-items: center; min-height: 200px;
|
||||||
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;
|
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
.lp-cc-content { position: relative; z-index: 2; max-width: 60%; }
|
||||||
.carousel-arrow-left {
|
.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); }
|
||||||
left: 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);
|
||||||
}
|
}
|
||||||
|
.lp-cc-cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(0,0,0,.18); }
|
||||||
.carousel-arrow-right {
|
.lp-cc-visual { position: relative; width: 120px; height: 120px; }
|
||||||
right: var(--space-md);
|
.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 ===== */
|
.lp-arrow {
|
||||||
.landing-market {
|
position: absolute; top: 50%; transform: translateY(-50%); z-index: 3;
|
||||||
padding: var(--space-3xl) 0 var(--space-4xl);
|
width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
|
||||||
background: var(--color-bg-secondary);
|
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;
|
.lp-grid { display: grid; gap: var(--space-2xl); }
|
||||||
justify-content: center;
|
.lp-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
gap: var(--space-sm);
|
.lp-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
margin-bottom: var(--space-2xl);
|
.lp-grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tab {
|
/* 痛点卡 */
|
||||||
padding: 6px 16px;
|
.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); }
|
||||||
border-radius: var(--radius-full);
|
.lp-pain:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--color-border-hover); }
|
||||||
border: 1px solid var(--color-border);
|
.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); }
|
||||||
background: #FFFFFF;
|
.lp-pain-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
|
||||||
color: var(--color-text-secondary);
|
.lp-pain-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tab:hover {
|
/* 价值卡 */
|
||||||
border-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); }
|
||||||
color: var(--color-accent);
|
.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);
|
.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); }
|
||||||
border-color: var(--color-accent);
|
.lp-feature:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||||
color: #FFFFFF;
|
.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;
|
.lp-rank { list-style: none; display: flex; flex-direction: column; gap: var(--space-md); counter-reset: rank; }
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
.lp-rank-item {
|
||||||
gap: var(--space-xl);
|
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;
|
.lp-app-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2xl); }
|
||||||
border-radius: var(--radius-lg);
|
.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); }
|
||||||
overflow: hidden;
|
.lp-app-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||||
border: 1px solid var(--color-border);
|
.lp-app-cover { position: relative; height: 120px; }
|
||||||
cursor: pointer;
|
.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; }
|
||||||
transition: all var(--transition-fast);
|
.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; }
|
||||||
.market-app-card:hover {
|
.lp-app-desc { color: var(--color-text-tertiary); font-size: var(--text-sm); line-height: 1.6; height: 42px; overflow: hidden; }
|
||||||
transform: translateY(-4px);
|
.lp-app-meta { display: flex; justify-content: space-between; margin-top: var(--space-md); font-size: var(--text-xs); color: var(--color-text-secondary); }
|
||||||
box-shadow: var(--shadow-lg);
|
.lp-app-meta span { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
border-color: var(--color-accent-light);
|
.lp-more { text-align: center; margin-top: var(--space-3xl); }
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== CTA ===== */
|
/* ===== CTA ===== */
|
||||||
.landing-cta {
|
.lp-cta { padding: var(--space-5xl) 0; background: linear-gradient(135deg, #5B5FED 0%, #4F8DF9 100%); }
|
||||||
padding: var(--space-4xl) 0;
|
.lp-cta .lp-inner { text-align: center; color: #fff; }
|
||||||
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
|
.lp-cta-title { font-size: var(--text-2xl); font-weight: 800; }
|
||||||
text-align: center;
|
.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; }
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Footer ===== */
|
/* ===== Footer ===== */
|
||||||
.landing-footer {
|
.lp-footer { background: #fff; border-top: 1px solid var(--color-border); padding: var(--space-4xl) 0 var(--space-2xl); }
|
||||||
padding: var(--space-3xl) 0 var(--space-xl);
|
.lp-footer-grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: var(--space-2xl); }
|
||||||
background: var(--color-bg-tertiary);
|
.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; }
|
||||||
border-top: 1px solid var(--color-border);
|
.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;
|
@media (max-width: 1024px) {
|
||||||
justify-content: space-between;
|
.lp-hero-grid { grid-template-columns: 1fr; }
|
||||||
gap: var(--space-2xl);
|
.lp-hero-visual { min-height: 260px; margin-top: var(--space-2xl); }
|
||||||
margin-bottom: 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) {
|
@media (max-width: 768px) {
|
||||||
.landing-nav-links {
|
.lp-nav, .lp-header-actions { display: none; }
|
||||||
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;
|
@media (prefers-reduced-motion: reduce) {
|
||||||
}
|
* { transition: none !important; animation: none !important; }
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,39 +4,85 @@ import { mockAdBanners, mockApps } from '@/mock/data';
|
|||||||
import type { AdBanner, MarketApp } from '@/types';
|
import type { AdBanner, MarketApp } from '@/types';
|
||||||
import './LandingPage.css';
|
import './LandingPage.css';
|
||||||
|
|
||||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
/* ---------- 内联 Lucide 风格图标(满足"用 Lucide 图标、禁 emoji"铁律) ---------- */
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
const Icon: React.FC<{ d: React.ReactNode; size?: number }> = ({ d, size = 20 }) => (
|
||||||
{children}
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
</svg>
|
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 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 LandingPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [mobileMenu, setMobileMenu] = useState(false);
|
||||||
|
|
||||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
const banners = mockAdBanners.filter(b => b.status === 'active');
|
||||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||||
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
const topSkills = [...publishedApps].sort((a, b) => b.installCount - a.installCount).slice(0, 5);
|
||||||
const [toastMsg, setToastMsg] = useState('');
|
|
||||||
|
|
||||||
const filteredApps = appTypeFilter
|
|
||||||
? publishedApps.filter(a => a.type === appTypeFilter)
|
|
||||||
: publishedApps;
|
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
|
||||||
setToastMsg(msg);
|
|
||||||
setTimeout(() => setToastMsg(''), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAppClick = (appName: string) => {
|
|
||||||
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
|
|
||||||
setTimeout(() => navigate('/login'), 800);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto rotate carousel
|
|
||||||
const nextSlide = useCallback(() => {
|
const nextSlide = useCallback(() => {
|
||||||
if (banners.length === 0) return;
|
if (banners.length === 0) return;
|
||||||
setCurrentSlide(prev => (prev + 1) % banners.length);
|
setCurrentSlide(prev => (prev + 1) % banners.length);
|
||||||
@@ -44,153 +90,160 @@ const LandingPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (banners.length <= 1 || isPaused) return;
|
if (banners.length <= 1 || isPaused) return;
|
||||||
const timer = setInterval(nextSlide, 4000);
|
const timer = setInterval(nextSlide, 4500);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [banners.length, isPaused, nextSlide]);
|
}, [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 goLogin = () => navigate('/login');
|
||||||
const gradients: Record<string, string> = {
|
|
||||||
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
// 点击 logo 回到首页顶部(.lp 是滚动容器,用 scrollTo 而非 window)
|
||||||
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
const goTop = () => {
|
||||||
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
const el = document.querySelector('.lp') as HTMLElement | null;
|
||||||
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
if (el) el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
|
||||||
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
|
||||||
};
|
};
|
||||||
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="landing-page">
|
<div className="lp">
|
||||||
{/* Toast notification */}
|
{/* JSON-LD 富媒体摘要(SEO / GEO) */}
|
||||||
{toastMsg && (
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
<div className="landing-toast">{toastMsg}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hero / Nav bar */}
|
{/* 浮层玻璃顶栏 */}
|
||||||
<header className="landing-header">
|
<header className="lp-header glass">
|
||||||
<div className="landing-header-inner">
|
<div className="lp-header-inner">
|
||||||
<div className="landing-brand">
|
<div className="lp-brand" onClick={goTop} role="button" tabIndex={0}
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
onKeyDown={e => e.key === 'Enter' && goTop()} aria-label="回到顶部">
|
||||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
<svg width="30" height="30" viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
<rect width="32" height="32" rx="8" fill="#5B5FED" />
|
||||||
<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>
|
</svg>
|
||||||
<span className="brand-name">Qihu-AIFlow</span>
|
<span className="brand-name">七虎AI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="landing-nav-links">
|
|
||||||
<a href="#market">应用市场</a>
|
<nav className="lp-nav" aria-label="主导航">
|
||||||
<a href="#features">平台能力</a>
|
<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>
|
</nav>
|
||||||
<div className="landing-header-actions">
|
|
||||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
|
<div className="lp-header-actions">
|
||||||
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Hero Section */}
|
<main>
|
||||||
<section className="landing-hero">
|
{/* Hero */}
|
||||||
<div className="landing-hero-content">
|
<section className="lp-hero">
|
||||||
<h1 className="hero-title">
|
<div className="lp-hero-grid">
|
||||||
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
|
<div className="lp-hero-text">
|
||||||
</h1>
|
<div className="lp-eyebrow">AI PaaS 平台</div>
|
||||||
<p className="hero-desc">
|
<h1 className="lp-title">让每个人都能<br /><span className="lp-accent">用 AI 把事做成</span></h1>
|
||||||
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
|
<p className="lp-desc">
|
||||||
让每个人都能轻松搭建属于自己的AI自动化SOP。
|
七虎AI 是个人体验 AI、办公提效、AI 创收与自我提升的一站式交互平台。
|
||||||
|
低价订阅 <span className="kw">无限Token</span>,提供垂直可用的办公模板、可交易的市场与低成本养模型能力。
|
||||||
</p>
|
</p>
|
||||||
<div className="hero-actions">
|
<div className="lp-actions">
|
||||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
<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>
|
||||||
</button>
|
|
||||||
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
|
|
||||||
浏览应用市场
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-stats">
|
<div className="lp-stats">
|
||||||
<div className="hero-stat">
|
<div><span className="lp-stat-num">3,500+</span><span className="lp-stat-label">活跃用户</span></div>
|
||||||
<span className="stat-number">3,500+</span>
|
<div><span className="lp-stat-num">120+</span><span className="lp-stat-label">应用 / Skills</span></div>
|
||||||
<span className="stat-label">活跃用户</span>
|
<div><span className="lp-stat-num">99.8%</span><span className="lp-stat-label">服务可用率</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-stat">
|
|
||||||
<span className="stat-number">120+</span>
|
|
||||||
<span className="stat-label">应用/Skills</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-stat">
|
|
||||||
<span className="stat-number">99.8%</span>
|
{/* Hero 右侧:品牌渐变光斑 + 玻璃卡视觉 */}
|
||||||
<span className="stat-label">服务可用率</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Carousel Section */}
|
{/* 轮播广告位(技能展示) */}
|
||||||
{banners.length > 0 && (
|
{banners.length > 0 && (
|
||||||
<section className="landing-banners">
|
<section className="lp-band">
|
||||||
<div className="landing-section-inner">
|
<div className="lp-inner">
|
||||||
<h2 className="section-title">推荐应用</h2>
|
<div className="lp-section-head">
|
||||||
<div
|
<h2 className="lp-h2">精选推荐</h2>
|
||||||
className="carousel-container"
|
<p className="lp-sub">平台热门技能广告位,提升展现与下载</p>
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
|
||||||
>
|
|
||||||
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
|
||||||
{banners.map(banner => (
|
|
||||||
<div key={banner.id} className="carousel-slide">
|
|
||||||
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
|
||||||
<div className="carousel-card-content">
|
|
||||||
<div className="carousel-badge">推荐</div>
|
|
||||||
<h3 className="carousel-title">{banner.title}</h3>
|
|
||||||
{banner.linkAppName && (
|
|
||||||
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="carousel-cta"
|
|
||||||
onClick={() => navigate('/login')}
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel-card-visual">
|
<div className="lp-carousel" onMouseEnter={() => setIsPaused(true)} onMouseLeave={() => setIsPaused(false)}>
|
||||||
<div className="visual-circle c1"/>
|
<div className="lp-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
||||||
<div className="visual-circle c2"/>
|
{banners.map(b => (
|
||||||
<div className="visual-circle c3"/>
|
<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>
|
||||||
|
<div className="lp-cc-visual"><span className="c1" /><span className="c2" /><span className="c3" /></div>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{banners.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
className="carousel-arrow carousel-arrow-left"
|
<button className="lp-arrow lp-arrow-r" onClick={nextSlide} aria-label="下一张"><Icon d={<polyline points="9 18 15 12 9 6" />} size={20} /></button>
|
||||||
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
|
<div className="lp-dots">{banners.map((_, i) => (<button key={i} className={`lp-dot ${i === currentSlide ? 'on' : ''}`} onClick={() => setCurrentSlide(i)} aria-label={`第${i + 1}张`} />))}</div>
|
||||||
>
|
|
||||||
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="carousel-arrow carousel-arrow-right"
|
|
||||||
onClick={() => nextSlide()}
|
|
||||||
>
|
|
||||||
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -198,169 +251,154 @@ const LandingPage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Market Preview Section */}
|
{/* 平台定位(痛点) */}
|
||||||
<section className="landing-market" id="market">
|
<section className="lp-section" id="positioning">
|
||||||
<div className="landing-section-inner">
|
<div className="lp-inner">
|
||||||
<div className="section-header">
|
<div className="lp-section-head">
|
||||||
<h2 className="section-title">应用市场</h2>
|
<h2 className="lp-h2">我们解决什么</h2>
|
||||||
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
|
<p className="lp-sub">七虎AI 帮你绕过个人与职场使用 AI 时最高频的痛点</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type Filter */}
|
|
||||||
<div className="market-filter-tabs">
|
|
||||||
<button
|
|
||||||
className={`filter-tab ${appTypeFilter === '' ? 'active' : ''}`}
|
|
||||||
onClick={() => setAppTypeFilter('')}
|
|
||||||
>全部</button>
|
|
||||||
<button
|
|
||||||
className={`filter-tab ${appTypeFilter === 'skill' ? 'active' : ''}`}
|
|
||||||
onClick={() => setAppTypeFilter('skill')}
|
|
||||||
>Skills</button>
|
|
||||||
<button
|
|
||||||
className={`filter-tab ${appTypeFilter === 'agent' ? 'active' : ''}`}
|
|
||||||
onClick={() => setAppTypeFilter('agent')}
|
|
||||||
>Agent</button>
|
|
||||||
<button
|
|
||||||
className={`filter-tab ${appTypeFilter === 'mcp' ? 'active' : ''}`}
|
|
||||||
onClick={() => setAppTypeFilter('mcp')}
|
|
||||||
>MCP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App Grid */}
|
|
||||||
<div className="market-app-grid">
|
|
||||||
{filteredApps.map(app => (
|
|
||||||
<div key={app.id} className="market-app-card" onClick={() => handleAppClick(app.name)}>
|
|
||||||
<div className="app-card-cover" style={{ background: getGradient(app) }}>
|
|
||||||
<span className="app-card-type-badge" style={{ background: typeColors[app.type] }}>
|
|
||||||
{typeLabels[app.type]}
|
|
||||||
</span>
|
|
||||||
{app.price > 0 && (
|
|
||||||
<span className="app-card-price">{app.price} 元</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="app-card-body">
|
|
||||||
<h4 className="app-card-name">{app.name}</h4>
|
|
||||||
<p className="app-card-desc">{app.description}</p>
|
|
||||||
<div className="app-card-meta">
|
|
||||||
<span className="app-card-rating">
|
|
||||||
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
|
|
||||||
{app.ratingAvg > 0 ? app.ratingAvg : '-'}
|
|
||||||
</span>
|
|
||||||
<span className="app-card-installs">{app.installCount.toLocaleString()} 次安装</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="market-more">
|
{/* 平台价值 */}
|
||||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
|
<section className="lp-section lp-section-alt" id="value">
|
||||||
登录查看更多应用
|
<div className="lp-inner">
|
||||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
<div className="lp-section-head">
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* 热门技能榜 Top5 */}
|
||||||
<section className="landing-features" id="features">
|
<section className="lp-section" id="top">
|
||||||
<div className="landing-section-inner">
|
<div className="lp-inner">
|
||||||
<div className="section-header">
|
<div className="lp-section-head">
|
||||||
<h2 className="section-title">平台核心能力</h2>
|
<h2 className="lp-h2">热门技能榜 · Top 5</h2>
|
||||||
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
|
<p className="lp-sub">按下载 / 安装量排行,看看大家都在用哪些能力</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="features-grid">
|
<ol className="lp-rank">
|
||||||
<div className="feature-card">
|
{topSkills.map((app, i) => (
|
||||||
<div className="feature-icon">
|
<li key={app.id} className="lp-rank-item" onClick={goLogin} role="button" tabIndex={0}
|
||||||
<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>
|
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>
|
</div>
|
||||||
<h3 className="feature-title">多模型AI对话</h3>
|
</section>
|
||||||
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
|
|
||||||
|
{/* 应用市场预览 */}
|
||||||
|
<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>
|
||||||
<div className="feature-card">
|
<div className="lp-app-grid">
|
||||||
<div className="feature-icon">
|
{publishedApps.map(app => (
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<h3 className="feature-title">私有知识库</h3>
|
<div className="lp-app-body">
|
||||||
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<h3 className="feature-title">应用市场</h3>
|
</article>
|
||||||
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
|
))}
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">
|
|
||||||
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
|
||||||
</div>
|
|
||||||
<h3 className="feature-title">Token驾驶舱</h3>
|
|
||||||
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
|
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">
|
|
||||||
<SVGIcon size={28}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon>
|
|
||||||
</div>
|
|
||||||
<h3 className="feature-title">IM频道绑定</h3>
|
|
||||||
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
|
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">
|
|
||||||
<SVGIcon size={28}><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon>
|
|
||||||
</div>
|
|
||||||
<h3 className="feature-title">自动化任务编排</h3>
|
|
||||||
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* 平台能力 */}
|
||||||
<section className="landing-cta">
|
<section className="lp-section" id="features">
|
||||||
<div className="landing-section-inner">
|
<div className="lp-inner">
|
||||||
<h2 className="cta-title">准备好开始了吗?</h2>
|
<div className="lp-section-head">
|
||||||
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
|
<h2 className="lp-h2">平台核心能力</h2>
|
||||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
<p className="lp-sub">从对话到自动化,七虎AI 提供完整的 AI 工作流解决方案</p>
|
||||||
免费注册,立即体验
|
</div>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* CTA */}
|
||||||
<footer className="landing-footer">
|
<section className="lp-cta">
|
||||||
<div className="landing-section-inner">
|
<div className="lp-inner">
|
||||||
<div className="footer-content">
|
<h2 className="lp-cta-title">准备好开始了吗?</h2>
|
||||||
<div className="footer-brand">
|
<p className="lp-cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
|
||||||
<div className="landing-brand">
|
<button className="lp-btn lp-btn-primary lp-btn-lg" onClick={goLogin}>免费注册,立即体验</button>
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
</div>
|
||||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
</section>
|
||||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
</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="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
|
<span className="brand-name">七虎AI</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
|
<p>个人体验 AI、办公提效与 AI 创收的一站式 PaaS 平台。</p>
|
||||||
AI智能自动化PaaS平台
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<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="footer-col">
|
<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>
|
||||||
<h4>产品</h4>
|
<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>
|
||||||
<a>AI聊天</a>
|
|
||||||
<a>知识库</a>
|
|
||||||
<a>应用市场</a>
|
|
||||||
</div>
|
|
||||||
<div className="footer-col">
|
|
||||||
<h4>支持</h4>
|
|
||||||
<a>帮助文档</a>
|
|
||||||
<a>API参考</a>
|
|
||||||
<a>联系我们</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="footer-bottom">
|
|
||||||
<span>Qihu-AIFlow 2026. All rights reserved.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="lp-footer-bottom">© 2026 七虎AI · 让每个人都能用 AI 把事做成</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -983,6 +983,37 @@
|
|||||||
font-weight: 500;
|
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 {
|
.hub-platforms {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ const skillsHubPlatforms = [
|
|||||||
|
|
||||||
const mcpHubPlatforms = [
|
const mcpHubPlatforms = [
|
||||||
{ id: 'modelscope-mcp', name: 'ModelScope MCP', url: 'https://modelscope.cn/mcp' },
|
{ 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) =====
|
// ===== Agent External Platforms (修改4) =====
|
||||||
@@ -580,7 +582,7 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
<p>系统将为你在AI聊天中创建一个临时新对话窗口。接下来的所有对话内容都将转化为该Skills工具的核心定义——包括功能描述、使用方式、参数配置和输出格式。</p>
|
<p>系统将为你在AI聊天中创建一个临时新对话窗口。接下来的所有对话内容都将转化为该Skills工具的核心定义——包括功能描述、使用方式、参数配置和输出格式。</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="form-submit-btn" onClick={() => {
|
<button className="form-submit-btn" onClick={() => {
|
||||||
window.location.hash = '#/chat';
|
window.location.href = '/chat';
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
前往AI聊天
|
前往AI聊天
|
||||||
@@ -868,12 +870,13 @@ interface HubImportModalProps {
|
|||||||
appType: 'skill' | 'mcp';
|
appType: 'skill' | 'mcp';
|
||||||
platforms: typeof skillsHubPlatforms;
|
platforms: typeof skillsHubPlatforms;
|
||||||
onClose: () => void;
|
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 HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onClose, onImport }) => {
|
||||||
const [importUrl, setImportUrl] = useState<Record<string, string>>({});
|
const [importUrl, setImportUrl] = useState<Record<string, string>>({});
|
||||||
const [importingId, setImportingId] = useState<string | null>(null);
|
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 [importStatus, setImportStatus] = useState<'idle' | 'verifying' | 'checking_format' | 'loading' | 'deploying' | 'published' | 'error'>('idle');
|
||||||
const [statusMsg, setStatusMsg] = useState('');
|
const [statusMsg, setStatusMsg] = useState('');
|
||||||
|
|
||||||
@@ -927,7 +930,7 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
setStatusMsg(step.msg);
|
setStatusMsg(step.msg);
|
||||||
if (step.status === 'published') {
|
if (step.status === 'published') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onImport(platformId, appType === 'mcp' ? hubCode : url, extractSkillName(url, platformId));
|
onImport(platformId, appType === 'mcp' ? hubCode : url, extractSkillName(url, platformId), importPrice);
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
setImportStatus('idle');
|
setImportStatus('idle');
|
||||||
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
|
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-arrow"/>
|
||||||
<div className="hub-step"><span className="hub-step-num">3</span> {appType === 'skill' ? '粘贴链接至输入框并点击导入' : '粘贴代码到编辑器,通过AI验证后点击导入'}</div>
|
<div className="hub-step"><span className="hub-step-num">3</span> {appType === 'skill' ? '粘贴链接至输入框并点击导入' : '粘贴代码到编辑器,通过AI验证后点击导入'}</div>
|
||||||
</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验证 */}
|
{/* MCP Hub Import: Code Editor + AI验证 */}
|
||||||
{appType === 'mcp' && (
|
{appType === 'mcp' && (
|
||||||
@@ -1093,13 +1110,14 @@ const mcpClient = new MCPClient({
|
|||||||
// ===== Agent Platform Connect Modal (修改4) =====
|
// ===== Agent Platform Connect Modal (修改4) =====
|
||||||
interface AgentConnectModalProps {
|
interface AgentConnectModalProps {
|
||||||
onClose: () => void;
|
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 AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [endpoint, setEndpoint] = useState('');
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [price, setPrice] = useState<number>(0);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [connected, setConnected] = useState<string | null>(null);
|
const [connected, setConnected] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1109,7 +1127,7 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(selectedPlatform);
|
setConnected(selectedPlatform);
|
||||||
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() });
|
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1177,6 +1195,19 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
onChange={e => setEndpoint(e.target.value)}
|
onChange={e => setEndpoint(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="form-submit-btn"
|
className="form-submit-btn"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
@@ -1286,14 +1317,14 @@ const MarketPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle hub import - 调用后端 API 创建应用
|
// 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 {
|
try {
|
||||||
await marketAPI.createApp({
|
await marketAPI.createApp({
|
||||||
name: skillName,
|
name: skillName,
|
||||||
type: showHubImport || 'skill',
|
type: showHubImport || 'skill',
|
||||||
description: `从 ${platformName} 导入的${showHubImport === 'skill' ? 'Skills工具' : 'MCP应用'} (来源: ${url})`,
|
description: `从 ${platformName} 导入的${showHubImport === 'skill' ? 'Skills工具' : 'MCP应用'} (来源: ${url})`,
|
||||||
price: 0,
|
price: price || 0,
|
||||||
is_member_free: true,
|
is_member_free: (price || 0) === 0,
|
||||||
join_promotion: false,
|
join_promotion: false,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
});
|
});
|
||||||
@@ -1306,7 +1337,7 @@ const MarketPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle agent connect - 调用后端 API 创建应用
|
// 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);
|
const platform = agentPlatforms.find(p => p.id === platformId);
|
||||||
if (!platform) return;
|
if (!platform) return;
|
||||||
try {
|
try {
|
||||||
@@ -1314,8 +1345,8 @@ const MarketPage: React.FC = () => {
|
|||||||
name: `${platform.name} Agent`,
|
name: `${platform.name} Agent`,
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
description: `从 ${platform.name} 对接导入的智能Agent`,
|
description: `从 ${platform.name} 对接导入的智能Agent`,
|
||||||
price: 0,
|
price: price || 0,
|
||||||
is_member_free: true,
|
is_member_free: (price || 0) === 0,
|
||||||
join_promotion: false,
|
join_promotion: false,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
agent_endpoint: config.endpoint || '',
|
agent_endpoint: config.endpoint || '',
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ const PricingPage: React.FC = () => {
|
|||||||
setShowModal(null);
|
setShowModal(null);
|
||||||
// 跳转到聊天页面,带定制服务ID
|
// 跳转到聊天页面,带定制服务ID
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.hash = '#/chat?service_id=' + res.data.id;
|
window.location.href = '/chat?service_id=' + res.data.id;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -678,8 +678,8 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="membership-actions">
|
<div className="membership-actions">
|
||||||
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
|
<button className="btn-primary" onClick={() => { window.location.href = '/pricing'; }}>续费会员</button>
|
||||||
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
|
<button className="btn-secondary" onClick={() => { window.location.href = '/pricing'; }}>升级会员</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -692,7 +692,7 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
window.location.hash = '#/pricing';
|
window.location.href = '/pricing';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
开通会员
|
开通会员
|
||||||
|
|||||||
Reference in new issue
Block a user