feat(frontend): HashRouter->BrowserRouter 干净URL可独立索引 + 静态预渲染(SSR注入#root/robots/sitemap) + logo回顶

This commit is contained in:
QihuAI Design committed 2026-07-13 15:01:53 +08:00
1 parent a1bea39d9f
commit d404aa4e29
12 files changed
+5859 -5607

No files matched your search

+84
View File
@@ -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
View File
@@ -38,7 +38,7 @@ export async function request<T>(
// 其他接口的401 = token过期/未登录,清除状态并跳转
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
window.location.hash = '#/login';
window.location.href = '/login';
throw new Error('未登录');
}
if (!res.ok) {
@@ -105,12 +105,12 @@ export const chatAPI = {
del<any>(`/chat/conversations/${id}`),
getMessages: (convId: string) =>
get<any[]>(`/chat/conversations/${convId}/messages`),
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_ids?: string[]; use_tools?: boolean }) =>
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_base_ids?: string[]; use_tools?: boolean }) =>
post<any>(`/chat/conversations/${convId}/messages`, data),
clearContext: (convId: string) =>
post<any>(`/chat/conversations/${convId}/clear`),
convertToKnowledgeGraph: (convId: string) =>
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
post<any>(`/chat/conversations/${convId}/to-knowledge-graph/`),
getModels: () => get<any[]>('/chat/models'),
};
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents`, data),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents/`, data),
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
getDirectories: () => get<any[]>('/knowledge/directories'),
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
@@ -315,11 +315,6 @@ export const adminAPI = {
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
getModelStats: () => get<any>('/admin/models/stats'),
// 能力类型 & 按能力默认模型
getCapabilities: () => get<any[]>('/admin/models/capabilities'),
getCapabilityDefaults: () => get<any>('/admin/models/capability-defaults'),
setCapabilityDefault: (capability: string, modelId: string | null) =>
put<any>('/admin/models/capability-defaults', { capability, model_id: modelId }),
// 网站设置
getSiteSettings: () => get<any>('/admin/settings/site'),
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
@@ -724,16 +719,7 @@ export const adminAPI = {
getTenantMembers: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/members`),
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
// ---- 以下 4 个方法为 1346fbb 之后新增, 保留 ----
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
getAdminApps: () => get<any[]>('/admin/market/apps'),
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
};
// ===== AI自进化API =====
export const evolutionAPI = {
// 驾驶舱面板
@@ -803,9 +789,212 @@ export const evolutionAPI = {
return get<import('@/types').EvolutionReport[]>(`/evolution/reports${qs}`);
},
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
// Workflow (Phase 3.6)
getWorkflows: (params?: Record<string, any>) =>
get<any>('/admin/workflows/?' + new URLSearchParams(params || {}).toString()),
getWorkflow: (id: string) =>
get<any>(`/admin/workflows/${id}`),
getWorkflowStats: () =>
get<any>('/admin/workflows/stats'),
getWorkflowTemplates: () =>
get<any>('/admin/workflows/templates'),
createWorkflow: (data: any) =>
post<any>('/admin/workflows/', data),
updateWorkflow: (id: string, data: any) =>
put<any>(`/admin/workflows/${id}`, data),
deleteWorkflow: (id: string) =>
del<any>(`/admin/workflows/${id}`),
toggleWorkflow: (id: string, isActive: boolean) =>
post<any>(`/admin/workflows/${id}/toggle?is_active=${isActive}`),
executeWorkflow: (id: string, inputText: string) =>
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
// Privacy & Local Agent (Phase G5)
getPrivacyStats: () => get<any>('/admin/privacy/stats'),
getPrivacyConfig: () => get<any>('/admin/privacy/config'),
updatePrivacyConfig: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); });
return put<any>('/admin/privacy/config?' + sp.toString());
},
switchPrivacyMode: (mode: string) => post<any>('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)),
getPrivacyLicenses: (params?: any) => {
const sp = new URLSearchParams();
if (params?.status) sp.append('status', params.status);
return get<any>('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
},
generateOfflineLicense: (data: any) => {
const sp = new URLSearchParams();
if (data.agent_id) sp.append('agent_id', data.agent_id);
sp.append('max_offline_days', String(data.max_offline_days || 7));
return post<any>('/admin/privacy/licenses/generate?' + sp.toString());
},
revokePrivacyLicense: (id: number) => post<any>('/admin/privacy/licenses/' + id + '/revoke'),
validatePrivacyLicense: (licenseKey: string) => {
const sp = new URLSearchParams();
sp.append('license_key', licenseKey);
return post<any>('/admin/privacy/licenses/validate?' + sp.toString());
},
getKbSyncStates: () => get<any>('/admin/privacy/kb-sync'),
triggerKbSync: (kbId: number, direction: string) => {
const sp = new URLSearchParams();
sp.append('knowledge_base_id', String(kbId));
sp.append('sync_direction', direction);
return post<any>('/admin/privacy/kb-sync/trigger?' + sp.toString());
},
getLocalAgents: () => get<any>('/admin/privacy/local-agents'),
getPrivacyUsageStats: () => get<any>('/admin/privacy/usage/stats'),
getBridgeConfig: () => get<any>('/admin/privacy/bridge-config'),
// IM Bot Management (Phase 4)
getImBotStats: () => get('/admin/im-bot/stats'),
getImBotDashboard: () => get('/admin/im-bot/dashboard'),
getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`),
getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`),
testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`),
activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`),
deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`),
getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`),
createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`),
deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`),
// 个人微信(iLink 协议)原生适配器
getWechatConfig: () => get('/admin/im-bot/wechat'),
wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'),
wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`),
getWechatStatus: () => get('/admin/im-bot/wechat/status'),
disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'),
getImBotPlatforms: () => get('/admin/im-bot/platforms'),
getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`),
getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`),
imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`),
// 推广系统
getPromotionStats: () => get<any>('/admin/promotion/stats'),
getPromotions: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return get<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
},
createPromotion: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return post<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
},
updatePromotion: (id: string, params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return put<any>(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`);
},
deletePromotion: (id: string) => del<any>(`/admin/promotion/promotions/${id}`),
togglePromotion: (id: string) => post<any>(`/admin/promotion/promotions/${id}/toggle`),
getReferralLinks: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/promotion/referral-links${sp.toString() ? `?${sp.toString()}` : ''}`);
},
generateReferralLink: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return post<any>(`/admin/promotion/referral-links/generate${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getReferralRewards: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`);
},
approveReward: (id: string) => post<any>(`/admin/promotion/rewards/${id}/approve`),
markRewardPaid: (id: string, params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return post<any>(`/admin/promotion/rewards/${id}/pay${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getPromotionLeaderboard: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`);
},
regenerateReferralCode: (id: string) => post<any>(`/admin/promotion/referral-links/${id}/regenerate`),
getMyReferrals: () => get<any>('/admin/promotion/my-referrals'),
getMyRewards: () => get<any>('/admin/promotion/my-rewards'),
// 成就系统
getAchievements: (params?: string) => get<any>(`/admin/achievement/achievements${params || ''}`),
getAchievementDetail: (id: string) => get<any>(`/admin/achievement/achievements/${id}`),
createAchievement: (params: string) => post<any>(`/admin/achievement/achievements${params}`),
updateAchievement: (id: string, params: string) => put<any>(`/admin/achievement/achievements/${id}${params}`),
deleteAchievement: (id: string) => del<any>(`/admin/achievement/achievements/${id}`),
getUserAchievements: () => get<any>('/admin/achievement/user/achievements'),
claimAchievementReward: (id: string) => post<any>(`/admin/achievement/user/achievements/${id}/claim`),
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
// 多租户管理
getTenants: (page?: number, keyword?: string, status?: string) => {
const sp = new URLSearchParams();
if (page) sp.set('page', String(page));
if (keyword) sp.set('keyword', keyword);
if (status) sp.set('status', status);
sp.set('page_size', '20');
return get<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
},
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
deleteTenant: (id: string) => del<any>(`/admin/tenant/${id}`),
getTenantMembers: (tenantId: string, page?: number) => {
const sp = new URLSearchParams();
sp.set('page', String(page || 1));
sp.set('page_size', '100');
return get<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
},
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
// 计费与支付
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
getBillingPackages: () => get<any>('/admin/billing/packages'),
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
// 数据分析
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
deleteAnalyticsDashboard: (id: string) => del<any>(`/admin/analytics/dashboards/${id}`),
getAnalyticsEvents: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
},
};
// ===== iLink 个人微信(前台用户) =====
// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
export const imUserAPI = {
getStatus: () => get<any>('/im/wechat/status'),
qrcode: () => post<any>('/im/wechat/qrcode'),
+236 -124
View File
@@ -1,124 +1,236 @@
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user || user.role !== 'admin') {
navigate('/login');
return null;
}
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav className="admin-nav">
{adminNavItems.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</nav>
<div className="admin-sidebar-footer">
<NavLink to="/chat" className="admin-nav-item">
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</span>
{!collapsed && <span className="nav-label">返回前台</span>}
</NavLink>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
{!collapsed && (
<div className="admin-version-info">
Qihu-AIFlow v2.0.0
</div>
)}
</aside>
{/* Admin Main Content */}
<main className="admin-main">
<header className="admin-topbar">
<div className="admin-topbar-left">
<span className="admin-welcome">欢迎,{user.username}</span>
</div>
<div className="admin-topbar-right">
<a href="#/chat" className="admin-topbar-action">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>返回前台</span>
</a>
<div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span>{user.username}</span>
</div>
</div>
</header>
<div className="admin-content">
<Outlet />
</div>
</main>
</div>
);
};
export default AdminLayout;
import React, { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
interface NavItem {
path: string;
icon: string;
label: string;
exact?: boolean;
}
interface NavGroup {
title: string;
icon: string;
items: NavItem[];
}
const adminNavGroups: NavGroup[] = [
{
title: '驾驶舱',
icon: 'dashboard',
items: [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
],
},
{
title: 'AI 能力',
icon: 'agents',
items: [
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP 服务' },
],
},
{
title: '应用与市场',
icon: 'market',
items: [
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
],
},
{
title: '用户运营',
icon: 'users',
items: [
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
],
},
{
title: '商业变现',
icon: 'billing',
items: [
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/assets', icon: 'assets', label: '资产管理' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
],
},
{
title: '系统管理',
icon: 'settings',
items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
],
},
{
title: '开发与集成',
icon: 'developer',
items: [
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
],
},
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
agents: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>,
skills: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
code: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
distro: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
privacy: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>,
promotion: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="7"/><path d="M8.5 12.5L6 21l6-2 6 2-2.5-8.5"/><circle cx="12" cy="8" r="3"/></svg>,
achievement: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>,
tenant: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21V8l9-5 9 5v13"/><path d="M9 21v-6h6v6"/><path d="M3 13h18"/></svg>,
billing: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>,
analytics: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/><line x1="3" y1="20" x2="21" y2="20"/></svg>,
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Default: all groups collapsed. Auto-expand the group containing the active route.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
const initial = new Set<string>();
const currentPath = location.pathname;
for (const group of adminNavGroups) {
const isActive = group.items.some(item =>
item.exact ? currentPath === item.path : currentPath.startsWith(item.path)
);
if (isActive) {
initial.add(group.title);
break;
}
}
return initial;
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(title)) next.delete(title);
else next.add(title);
return next;
});
};
const handleLogout = () => {
logout();
navigate('/');
};
const handleVisitFrontend = () => {
const url = window.location.origin + '/';
window.open(url, '_blank');
};
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar-header">
<h1 className="admin-logo">七虎AI</h1>
<span className="admin-subtitle">管理控制台</span>
</div>
<button onClick={handleVisitFrontend} className="admin-frontend-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span>访问前台</span>
</button>
<nav className="sidebar-nav">
{adminNavGroups.map((group) => {
const isExpanded = expandedGroups.has(group.title);
const hasActiveChild = group.items.some(item =>
item.exact ? 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>
{isExpanded && (
<div className="nav-group-items">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) =>
`nav-item ${isActive ? 'nav-item-active' : ''}`
}
>
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
<span className="nav-item-label">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
);
})}
</nav>
<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>
<main className="admin-content">
<Outlet />
</main>
</div>
);
};
export default AdminLayout;
+3 -3
View File
@@ -1,13 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<BrowserRouter>
<App />
</HashRouter>
</BrowserRouter>
</React.StrictMode>
);
+385 -227
View File
@@ -1,227 +1,385 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats(),
adminAPI.getRecentLogs(),
adminAPI.getRecentAudit(),
]);
if (statsRes.data) setStats(statsRes.data);
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatTokens = (n: number): string => {
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCards = [
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value">{s.value}</div>
</div>
</div>
))}
</div>
{/* Platform Cumulative Token Stats */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}平台累计Token消耗
</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
{[
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
{item.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
{item.label}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
</div>
))}
</div>
</div>
{/* Quick Entry Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
</div>
))}
</div>
{/* Recent Operation Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
interface DashboardStats {
total_users: number;
active_users: number;
total_models: number;
enabled_models: number;
total_conversations: number;
active_conversations: number;
total_knowledge_bases: number;
total_documents: number;
today_orders: number;
today_tokens: number;
week_tokens: number;
total_tokens: number;
today_conversations: number;
server_time: string;
}
interface DailyStat {
date: string;
tokens: number;
}
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
// SVG Bar Chart Component
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
const maxVal = Math.max(...data.map(d => d.tokens), 1);
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
const barAreaW = w - padLeft - padRight;
const barAreaH = h - padTop - padBottom;
const barGap = 2;
const barW = Math.max(2, (barAreaW / data.length) - barGap);
// Y axis ticks
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
const formatDate = (d: string) => {
const parts = d.split('-');
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
};
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
{/* Grid lines */}
{yTicks.map((tick, i) => {
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
return (
<g key={i}>
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const barH = (d.tokens / maxVal) * barAreaH;
const x = padLeft + i * (barAreaW / data.length);
const y = padTop + barAreaH - barH;
return (
<g key={i}>
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
</rect>
{/* X label - every 7th bar */}
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
)}
</g>
);
})}
</svg>
);
};
// Mini Donut for stat cards
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
const pct = max > 0 ? Math.min(value / max, 1) : 0;
const r = 16, c = 20, circumference = 2 * Math.PI * r;
const dash = circumference * pct;
return (
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
{Math.round(pct * 100)}%
</text>
</svg>
);
};
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats>({
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
total_conversations: 0, active_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
today_conversations: 0, server_time: '',
});
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats() as any,
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
adminAPI.getRecentLogs() as any,
adminAPI.getRecentAudit() as any,
]);
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
const statsData = statsRes?.data || statsRes;
if (statsData && typeof statsData === 'object') setStats(statsData);
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
// Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array
const logsData = Array.isArray(logsRes) ? logsRes : (Array.isArray(logsRes?.data) ? logsRes.data : []);
setOperationLogs(logsData);
// Recent audit: same as logs
const auditData = Array.isArray(auditRes) ? auditRes : (Array.isArray(auditRes?.data) ? auditRes.data : []);
setAuditLogs(auditData);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatTokens = (n: number): string => {
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCardDefs = [
{ label: '总用户', value: stats.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '对话数', value: stats.total_conversations, sub: `活跃 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
{ label: '知识库', value: stats.total_knowledge_bases, sub: `文档 ${stats.total_documents}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
icon: <SVGIcon size={22}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon> },
{ label: '今日对话', value: stats.today_conversations, sub: `订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards Row */}
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
{statCardDefs.map((s, i) => (
<div key={i} className="admin-stat-card" style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
padding: 'var(--space-lg) var(--space-xl)',
}}>
<div style={{
width: 48, height: 48, borderRadius: 14,
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
</div>
<div style={{
fontSize: 11, color: s.color, marginTop: 2,
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
display: 'inline-block',
}}>{s.sub}</div>
</div>
</div>
))}
</div>
{/* Charts Row */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
{/* Token Trend Chart */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}Token消耗趋势(近30天)
</h3>
</div>
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
<BarChart data={dailyStats} />
</div>
</div>
{/* System Health */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
{' '}平台概览
</h3>
</div>
<div style={{ padding: 'var(--space-lg)' }}>
{[
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
</div>
))}
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'}
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ marginBottom: 'var(--space-xl)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6C63FF';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}}
>
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Recent Logs */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
File diff suppressed because it is too large. Load diff
+218 -664
View File
@@ -1,704 +1,258 @@
/* ===== Landing Page Styles ===== */
/* ===== 七虎AI 前台首页 · 遵循 DESIGN.md(亮色/单一靛蓝/渐变/玻璃拟态/SEO) ===== */
/* LandingPage 不在 FrontLayout 壳内,而 global.css 全局锁死了 html/body/#root 的滚动;
故本页自成一个可滚动容器,避免内容超出 100vh 被裁切且无法翻阅。 */
.lp { color: var(--color-text-primary); background: var(--color-bg-primary); height: 100vh; overflow-y: auto; overflow-x: hidden; }
.landing-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-bg-primary);
z-index: var(--z-base);
-webkit-overflow-scrolling: touch;
/* 玻璃拟态工具类(DESIGN.md §4) */
.glass {
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: var(--shadow-md);
}
/* Toast notification */
.landing-toast {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
padding: 10px 24px;
background: var(--color-text-primary);
color: #FFFFFF;
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideDown var(--transition-normal);
white-space: nowrap;
/* ===== 顶栏 ===== */
.lp-header {
position: sticky; top: 0; z-index: var(--z-sticky);
border-bottom: 1px solid var(--color-border-light);
}
.lp-header-inner {
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
height: 64px; display: flex; align-items: center; gap: var(--space-2xl);
}
.lp-brand { display: flex; align-items: center; gap: var(--space-sm); }
.brand-name { font-weight: 700; font-size: var(--text-lg); letter-spacing: .2px; }
.lp-nav { display: flex; gap: var(--space-xl); flex: 1; }
.lp-nav a {
color: var(--color-text-secondary); text-decoration: none; font-size: var(--text-base);
font-weight: 500; cursor: pointer; transition: color var(--transition-fast);
}
.lp-nav a:hover { color: var(--color-accent); }
.lp-header-actions { display: flex; gap: var(--space-sm); align-items: center; }
.landing-section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
/* 按钮(DESIGN.md §4) */
.lp-btn {
font-family: var(--font-sans); font-size: var(--text-base); font-weight: 600;
border-radius: var(--radius-md); padding: 9px 18px; cursor: pointer;
transition: all var(--transition-normal); border: 1px solid transparent; white-space: nowrap;
}
.lp-btn-lg { padding: 13px 26px; font-size: var(--text-md); }
.lp-btn-primary { background: var(--color-accent); color: #fff; }
.lp-btn-primary:hover { background: var(--color-accent-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); }
.lp-btn-outline { background: transparent; color: var(--color-accent); border-color: var(--color-accent); }
.lp-btn-outline:hover { background: var(--color-accent-subtle); transform: translateY(-1px); }
.lp-btn-ghost { background: transparent; color: var(--color-text-secondary); }
.lp-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
/* ===== Header ===== */
.landing-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
.lp-menu-toggle { display: none; background: transparent; border: none; color: var(--color-text-primary); cursor: pointer; margin-left: auto; }
.lp-mobile-menu {
display: flex; flex-direction: column; gap: var(--space-xs);
padding: var(--space-md) var(--space-2xl) var(--space-2xl);
border-top: 1px solid var(--color-border-light); background: var(--color-bg-secondary);
}
.lp-mobile-menu a { color: var(--color-text-secondary); text-decoration: none; padding: 12px 0; font-weight: 500; cursor: pointer; }
.lp-mobile-actions { display: flex; gap: var(--space-sm); margin-top: var(--space-sm); }
.lp-mobile-actions .lp-btn { flex: 1; }
.landing-header-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
height: 60px;
display: flex;
align-items: center;
gap: var(--space-2xl);
}
.landing-brand {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.brand-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.landing-nav-links {
display: flex;
gap: var(--space-xl);
}
.landing-nav-links a {
font-size: var(--text-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
.landing-nav-links a:hover {
color: var(--color-accent);
}
.landing-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-md);
}
/* ===== Buttons ===== */
.landing-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
transition: all var(--transition-fast);
font-family: inherit;
text-decoration: none;
}
.landing-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.landing-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: 0 2px 12px rgba(91, 95, 237, 0.35);
}
.landing-btn-outline {
background: transparent;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.landing-btn-outline:hover {
background: var(--color-accent-subtle);
}
.landing-btn-lg {
padding: 12px 28px;
font-size: var(--text-base);
}
/* ===== 通用容器 / 标题 ===== */
.lp-inner { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl); }
.lp-section { padding: var(--space-5xl) 0; }
/* 锚点跳转时避开 sticky 顶栏遮挡 */
.lp-section[id] { scroll-margin-top: 76px; }
.lp-section-alt { background: var(--color-bg-tertiary); }
.lp-section-head { text-align: center; max-width: 720px; margin: 0 auto var(--space-4xl); }
.lp-h2 { font-size: var(--text-2xl); font-weight: 700; letter-spacing: -.2px; }
.lp-sub { color: var(--color-text-secondary); font-size: var(--text-md); margin-top: var(--space-sm); }
/* ===== Hero ===== */
.landing-hero {
padding: 80px 0 72px;
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
}
.landing-hero-content {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.hero-title {
font-size: 48px;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1.2;
letter-spacing: -0.03em;
margin: 0 0 var(--space-xl);
}
.hero-accent {
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-desc {
font-size: var(--text-lg);
color: var(--color-text-secondary);
max-width: 640px;
line-height: 1.7;
margin: 0 0 var(--space-2xl);
}
.hero-actions {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-3xl);
}
.hero-stats {
display: flex;
gap: var(--space-3xl);
}
.hero-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: var(--color-accent);
}
.stat-label {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
/* ===== Carousel ===== */
.landing-banners {
padding: var(--space-3xl) 0;
}
.section-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-xl);
}
.section-header {
text-align: center;
margin-bottom: var(--space-2xl);
}
.section-desc {
font-size: var(--text-base);
color: var(--color-text-tertiary);
margin: var(--space-sm) 0 0;
}
.carousel-container {
position: relative;
.lp-hero {
background: linear-gradient(180deg, #EEEDFF 0%, #F8F9FB 42%);
padding: var(--space-5xl) 0 var(--space-4xl);
overflow: hidden;
border-radius: var(--radius-xl);
}
.carousel-track {
display: flex;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
.lp-hero-grid {
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
display: grid; grid-template-columns: 1.05fr .95fr; gap: var(--space-4xl); align-items: center;
}
.carousel-slide {
min-width: 100%;
box-sizing: border-box;
.lp-eyebrow {
display: inline-block; font-size: var(--text-sm); font-weight: 600; color: var(--color-accent);
background: var(--color-accent-light); padding: 4px 12px; border-radius: var(--radius-full); margin-bottom: var(--space-lg);
}
.lp-title { font-size: var(--text-3xl); font-weight: 800; line-height: 1.25; letter-spacing: -.5px; }
.lp-accent { color: var(--color-accent); }
.lp-desc { color: var(--color-text-secondary); font-size: var(--text-md); line-height: 1.7; margin: var(--space-lg) 0 var(--space-2xl); max-width: 520px; }
.kw { color: var(--color-accent); font-weight: 600; }
.lp-actions { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.lp-stats { display: flex; gap: var(--space-3xl); margin-top: var(--space-3xl); }
.lp-stats > div { display: flex; flex-direction: column; }
.lp-stat-num { font-size: var(--text-xl); font-weight: 800; color: var(--color-text-primary); }
.lp-stat-label { font-size: var(--text-sm); color: var(--color-text-tertiary); margin-top: 2px; }
.carousel-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3xl) var(--space-3xl);
border-radius: var(--radius-xl);
min-height: 200px;
color: #FFFFFF;
overflow: hidden;
position: relative;
.lp-hero-visual { position: relative; min-height: 320px; display: flex; align-items: center; justify-content: center; }
.lp-hero-glow {
position: absolute; width: 320px; height: 320px; border-radius: 50%;
background: radial-gradient(circle, rgba(91,95,237,.22), transparent 70%);
filter: blur(8px);
}
.lp-hero-card { position: relative; width: 100%; max-width: 380px; border-radius: var(--radius-xl); padding: var(--space-2xl); display: flex; flex-direction: column; gap: var(--space-md); }
.lp-hc-row { display: flex; align-items: center; gap: var(--space-sm); font-size: var(--text-sm); color: var(--color-text-secondary); font-weight: 500; }
.lp-hc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); box-shadow: 0 0 0 3px var(--color-success-light); }
.lp-hc-bubble { background: var(--color-bg-tertiary); border-radius: var(--radius-lg); padding: var(--space-md); font-size: var(--text-sm); color: var(--color-text-primary); }
.lp-hc-bubble-ai { background: var(--color-accent-light); color: var(--color-accent); font-weight: 500; }
.lp-hc-bar { height: 6px; border-radius: var(--radius-full); background: var(--color-bg-tertiary); overflow: hidden; }
.lp-hc-bar > span { display: block; height: 100%; width: 70%; border-radius: var(--radius-full); background: linear-gradient(90deg, #5B5FED, #4F8DF9); }
.carousel-card-content {
z-index: 1;
}
.carousel-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(4px);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
margin-bottom: var(--space-md);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.carousel-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 var(--space-sm);
line-height: 1.3;
}
.carousel-subtitle {
font-size: var(--text-base);
opacity: 0.9;
margin: 0 0 var(--space-xl);
}
.carousel-cta {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
border-radius: var(--radius-md);
color: #FFFFFF;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
font-family: inherit;
}
.carousel-cta:hover {
background: rgba(255,255,255,0.35);
border-color: rgba(255,255,255,0.6);
}
.carousel-card-visual {
position: relative;
width: 160px;
height: 160px;
flex-shrink: 0;
}
.visual-circle {
position: absolute;
border-radius: 50%;
background: rgba(255,255,255,0.15);
}
.visual-circle.c1 {
width: 140px;
height: 140px;
top: 10px;
right: 0;
}
.visual-circle.c2 {
width: 90px;
height: 90px;
top: 50px;
right: 50px;
background: rgba(255,255,255,0.2);
}
.visual-circle.c3 {
width: 50px;
height: 50px;
top: 20px;
right: 20px;
background: rgba(255,255,255,0.25);
}
/* Dots */
.carousel-dots {
position: absolute;
bottom: var(--space-lg);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-sm);
z-index: 2;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.4);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0;
}
.carousel-dot.active {
background: #FFFFFF;
width: 24px;
border-radius: 4px;
}
/* Arrows */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.9);
color: var(--color-text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
z-index: 2;
}
.carousel-arrow:hover {
background: #FFFFFF;
/* ===== 轮播广告位 ===== */
.lp-band { padding: var(--space-4xl) 0; }
.lp-carousel { position: relative; }
.lp-track { display: flex; transition: transform var(--transition-slow); }
.lp-slide { min-width: 100%; padding: 0 var(--space-xs); }
.lp-carousel-card {
border-radius: var(--radius-xl); padding: var(--space-4xl); color: #fff; position: relative; overflow: hidden;
display: flex; justify-content: space-between; align-items: center; min-height: 200px;
box-shadow: var(--shadow-lg);
}
.carousel-arrow-left {
left: var(--space-md);
.lp-cc-content { position: relative; z-index: 2; max-width: 60%; }
.lp-cc-badge { display: inline-block; background: rgba(255,255,255,.22); padding: 4px 12px; border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: 600; margin-bottom: var(--space-md); }
.lp-cc-title { font-size: var(--text-xl); font-weight: 700; }
.lp-cc-sub { font-size: var(--text-sm); opacity: .9; margin-top: var(--space-xs); }
.lp-cc-cta {
margin-top: var(--space-lg); display: inline-flex; align-items: center; gap: 6px;
background: #fff; color: var(--color-accent); border: none; border-radius: var(--radius-md);
padding: 9px 18px; font-weight: 600; font-size: var(--text-sm); cursor: pointer; transition: all var(--transition-normal);
}
.carousel-arrow-right {
right: var(--space-md);
.lp-cc-cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(0,0,0,.18); }
.lp-cc-visual { position: relative; width: 120px; height: 120px; }
.lp-cc-visual .c1, .lp-cc-visual .c2, .lp-cc-visual .c3 {
position: absolute; border-radius: 50%; background: rgba(255,255,255,.25);
}
.lp-cc-visual .c1 { width: 70px; height: 70px; top: 0; right: 0; }
.lp-cc-visual .c2 { width: 48px; height: 48px; bottom: 6px; right: 30px; }
.lp-cc-visual .c3 { width: 30px; height: 30px; bottom: 0; left: 0; }
/* ===== Market ===== */
.landing-market {
padding: var(--space-3xl) 0 var(--space-4xl);
background: var(--color-bg-secondary);
.lp-arrow {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 3;
width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
background: rgba(255,255,255,.85); color: var(--color-accent); display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow-md); transition: all var(--transition-normal);
}
.lp-arrow:hover { background: #fff; transform: translateY(-50%) scale(1.05); }
.lp-arrow-l { left: 12px; } .lp-arrow-r { right: 12px; }
.lp-dots { display: flex; justify-content: center; gap: var(--space-sm); margin-top: var(--space-lg); }
.lp-dot { width: 8px; height: 8px; border-radius: 50%; border: none; background: var(--color-border-hover); cursor: pointer; transition: all var(--transition-fast); }
.lp-dot.on { width: 24px; border-radius: var(--radius-full); background: var(--color-accent); }
.market-filter-tabs {
display: flex;
justify-content: center;
gap: var(--space-sm);
margin-bottom: var(--space-2xl);
}
/* ===== 栅格 ===== */
.lp-grid { display: grid; gap: var(--space-2xl); }
.lp-grid-3 { grid-template-columns: repeat(3, 1fr); }
.lp-grid-4 { grid-template-columns: repeat(4, 1fr); }
.lp-grid-5 { grid-template-columns: repeat(5, 1fr); }
.filter-tab {
padding: 6px 16px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
background: #FFFFFF;
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
/* 痛点卡 */
.lp-pain { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
.lp-pain:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--color-border-hover); }
.lp-pain-icon { width: 48px; height: 48px; border-radius: var(--radius-md); background: var(--color-accent-light); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
.lp-pain-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
.lp-pain-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
.filter-tab:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* 价值卡 */
.lp-value { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
.lp-value:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.lp-value-icon { width: 48px; height: 48px; border-radius: var(--radius-md); background: var(--gradient-brand, #EEEDFF); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
.lp-value-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
.lp-value-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
.filter-tab.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #FFFFFF;
}
/* 能力卡 */
.lp-feature { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl); transition: all var(--transition-normal); }
.lp-feature:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.lp-feature-icon { width: 52px; height: 52px; border-radius: var(--radius-md); background: var(--color-accent-light); color: var(--color-accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-lg); }
.lp-feature-title { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-sm); }
.lp-feature-desc { color: var(--color-text-secondary); font-size: var(--text-sm); line-height: 1.65; }
.market-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--space-xl);
/* ===== 热门技能榜 ===== */
.lp-rank { list-style: none; display: flex; flex-direction: column; gap: var(--space-md); counter-reset: rank; }
.lp-rank-item {
display: grid; grid-template-columns: 44px 64px 1fr auto; gap: var(--space-lg); align-items: center;
background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg);
padding: var(--space-lg) var(--space-xl); cursor: pointer; transition: all var(--transition-normal);
}
.lp-rank-item:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--color-accent-light); }
.lp-rank-no { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: var(--color-bg-tertiary); color: var(--color-text-secondary); font-weight: 700; font-size: var(--text-md); }
.lp-rank-no.top { background: linear-gradient(135deg, #5B5FED, #4F8DF9); color: #fff; box-shadow: var(--shadow-md); }
.lp-rank-cover { position: relative; width: 64px; height: 64px; border-radius: var(--radius-md); overflow: hidden; }
.lp-rank-badge { position: absolute; top: 6px; left: 6px; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: var(--radius-full); color: #fff; }
.badge-skill { background: var(--color-success); }
.badge-agent { background: var(--color-accent); }
.badge-mcp { background: var(--color-warning); }
.lp-rank-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.lp-rank-name { font-weight: 600; font-size: var(--text-md); }
.lp-rank-desc { color: var(--color-text-tertiary); font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lp-rank-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; color: var(--color-text-secondary); font-size: var(--text-sm); }
.lp-rank-installs { display: inline-flex; align-items: center; gap: 4px; color: var(--color-accent); font-weight: 600; }
.lp-rank-rating { color: var(--color-warning); font-weight: 600; }
.market-app-card {
background: #FFFFFF;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--transition-fast);
}
.market-app-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent-light);
}
.app-card-cover {
height: 120px;
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: var(--space-md);
gap: var(--space-sm);
}
.app-card-type-badge {
font-size: 11px;
font-weight: 600;
color: #FFFFFF;
padding: 2px 8px;
border-radius: var(--radius-full);
letter-spacing: 0.03em;
}
.app-card-price {
position: absolute;
top: var(--space-md);
left: var(--space-md);
font-size: var(--text-sm);
font-weight: 700;
color: #FFFFFF;
background: rgba(0,0,0,0.3);
padding: 2px 10px;
border-radius: var(--radius-full);
}
.app-card-body {
padding: var(--space-lg);
}
.app-card-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-xs);
}
.app-card-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.app-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.app-card-rating {
display: flex;
align-items: center;
gap: 2px;
color: #F59E0B;
}
.app-card-rating svg {
fill: #F59E0B;
}
.market-more {
display: flex;
justify-content: center;
margin-top: var(--space-2xl);
}
/* ===== Features ===== */
.landing-features {
padding: var(--space-4xl) 0;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-xl);
}
.feature-card {
padding: var(--space-2xl);
background: #FFFFFF;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
transition: all var(--transition-fast);
}
.feature-card:hover {
border-color: var(--color-accent-light);
box-shadow: var(--shadow-md);
}
.feature-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
background: var(--color-accent-subtle);
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-lg);
}
.feature-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm);
}
.feature-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
margin: 0;
}
/* ===== 应用市场网格 ===== */
.lp-app-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2xl); }
.lp-app-card { background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; transition: all var(--transition-normal); }
.lp-app-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.lp-app-cover { position: relative; height: 120px; }
.lp-app-badge { position: absolute; top: 10px; left: 10px; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: var(--radius-full); color: #fff; }
.lp-app-price { position: absolute; top: 10px; right: 10px; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: var(--radius-full); background: rgba(255,255,255,.9); color: var(--color-text-primary); }
.lp-app-body { padding: var(--space-lg); }
.lp-app-name { font-size: var(--text-md); font-weight: 600; margin-bottom: 6px; }
.lp-app-desc { color: var(--color-text-tertiary); font-size: var(--text-sm); line-height: 1.6; height: 42px; overflow: hidden; }
.lp-app-meta { display: flex; justify-content: space-between; margin-top: var(--space-md); font-size: var(--text-xs); color: var(--color-text-secondary); }
.lp-app-meta span { display: inline-flex; align-items: center; gap: 4px; }
.lp-more { text-align: center; margin-top: var(--space-3xl); }
/* ===== CTA ===== */
.landing-cta {
padding: var(--space-4xl) 0;
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
text-align: center;
}
.cta-title {
font-size: 32px;
font-weight: 700;
color: #FFFFFF;
margin: 0 0 var(--space-md);
}
.cta-desc {
font-size: var(--text-lg);
color: rgba(255,255,255,0.85);
margin: 0 0 var(--space-2xl);
}
.landing-cta .landing-btn-primary {
background: #FFFFFF;
color: var(--color-accent);
}
.landing-cta .landing-btn-primary:hover {
background: #F0EEFF;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.lp-cta { padding: var(--space-5xl) 0; background: linear-gradient(135deg, #5B5FED 0%, #4F8DF9 100%); }
.lp-cta .lp-inner { text-align: center; color: #fff; }
.lp-cta-title { font-size: var(--text-2xl); font-weight: 800; }
.lp-cta-desc { opacity: .92; margin: var(--space-md) 0 var(--space-2xl); font-size: var(--text-md); }
.lp-cta .lp-btn-primary { background: #fff; color: var(--color-accent); }
.lp-cta .lp-btn-primary:hover { background: #f0f0ff; }
/* ===== Footer ===== */
.landing-footer {
padding: var(--space-3xl) 0 var(--space-xl);
background: var(--color-bg-tertiary);
border-top: 1px solid var(--color-border);
}
.lp-footer { background: #fff; border-top: 1px solid var(--color-border); padding: var(--space-4xl) 0 var(--space-2xl); }
.lp-footer-grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: var(--space-2xl); }
.lp-footer-brand p { color: var(--color-text-tertiary); font-size: var(--text-sm); margin-top: var(--space-md); max-width: 280px; line-height: 1.6; }
.lp-footer-col h4 { font-size: var(--text-sm); font-weight: 700; margin-bottom: var(--space-md); }
.lp-footer-col a, .lp-footer-col span { display: block; color: var(--color-text-secondary); font-size: var(--text-sm); margin-bottom: var(--space-sm); cursor: pointer; text-decoration: none; transition: color var(--transition-fast); }
.lp-footer-col a:hover { color: var(--color-accent); }
.lp-footer-bottom { margin-top: var(--space-3xl); padding-top: var(--space-lg); border-top: 1px solid var(--color-border-light); color: var(--color-text-tertiary); font-size: var(--text-xs); text-align: center; }
.footer-content {
display: flex;
justify-content: space-between;
gap: var(--space-2xl);
margin-bottom: var(--space-2xl);
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.lp-hero-grid { grid-template-columns: 1fr; }
.lp-hero-visual { min-height: 260px; margin-top: var(--space-2xl); }
.lp-grid-5 { grid-template-columns: repeat(3, 1fr); }
.lp-grid-4 { grid-template-columns: repeat(2, 1fr); }
.lp-app-grid { grid-template-columns: repeat(3, 1fr); }
}
.footer-links {
display: flex;
gap: var(--space-4xl);
}
.footer-col h4 {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-md);
}
.footer-col a {
display: block;
font-size: var(--text-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--space-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
.footer-col a:hover {
color: var(--color-accent);
}
.footer-bottom {
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.landing-nav-links {
display: none;
}
.lp-nav, .lp-header-actions { display: none; }
.lp-menu-toggle { display: inline-flex; }
.lp-section { padding: var(--space-3xl) 0; }
.lp-grid-5, .lp-grid-4, .lp-grid-3 { grid-template-columns: repeat(2, 1fr); }
.lp-app-grid { grid-template-columns: repeat(2, 1fr); }
.lp-footer-grid { grid-template-columns: 1fr 1fr; }
.lp-stats { gap: var(--space-2xl); }
.lp-rank-item { grid-template-columns: 36px 52px 1fr; }
.lp-rank-meta { display: none; }
}
@media (max-width: 480px) {
.lp-inner, .lp-header-inner, .lp-hero-grid { padding-left: var(--space-lg); padding-right: var(--space-lg); }
.lp-grid-5, .lp-grid-4, .lp-grid-3, .lp-app-grid { grid-template-columns: 1fr; }
.lp-footer-grid { grid-template-columns: 1fr; }
.lp-stats { flex-wrap: wrap; gap: var(--space-lg); }
.lp-title { font-size: var(--text-2xl); }
.lp-cc-content { max-width: 100%; }
.lp-cc-visual { display: none; }
}
.hero-title {
font-size: 32px;
}
.hero-stats {
flex-direction: column;
gap: var(--space-lg);
}
.carousel-card {
flex-direction: column;
text-align: center;
min-height: 240px;
}
.carousel-card-visual {
display: none;
}
.features-grid {
grid-template-columns: 1fr;
}
.market-app-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.footer-content {
flex-direction: column;
}
/* 尊重减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
+340 -302
View File
@@ -4,39 +4,85 @@ import { mockAdBanners, mockApps } from '@/mock/data';
import type { AdBanner, MarketApp } from '@/types';
import './LandingPage.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
/* ---------- 内联 Lucide 风格图标(满足"用 Lucide 图标、禁 emoji"铁律) ---------- */
const Icon: React.FC<{ d: React.ReactNode; size?: number }> = ({ d, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
/* ---------- 品牌同系渐变(仅靛蓝→青蓝,无第三色相) ---------- */
const appGradients: Record<string, string> = {
a1: 'linear-gradient(135deg,#5B5FED 0%,#4F8DF9 100%)',
a2: 'linear-gradient(135deg,#6366F1 0%,#60A5FA 100%)',
a3: 'linear-gradient(135deg,#5B5FED 0%,#818CF8 100%)',
a4: 'linear-gradient(135deg,#4F8DF9 0%,#38BDF8 100%)',
a5: 'linear-gradient(135deg,#6366F1 0%,#7DD3FC 100%)',
a6: 'linear-gradient(135deg,#5B5FED 0%,#A78BFA 100%)',
a7: 'linear-gradient(135deg,#4F8DF9 0%,#818CF8 100%)',
a8: 'linear-gradient(135deg,#6366F1 0%,#4F8DF9 100%)',
};
const gradFor = (id: string) => appGradients[id] || 'linear-gradient(135deg,#5B5FED 0%,#4F8DF9 100%)';
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
const typeBadge: Record<string, string> = {
skill: 'badge-skill', agent: 'badge-agent', mcp: 'badge-mcp',
};
/* ---------- 文案数据(来自用户原始需求提炼) ---------- */
const painPoints = [
{ d: <><path d="M12 1v22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
title: '大模型 API 贵且复杂', desc: '多家模型计费规则不一、调用门槛高,个人与小企业难以低成本接入。' },
{ d: <><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></>,
title: 'Prompt 难调试', desc: '缺乏可视化编排与复用,效果全靠手感,团队无法沉淀最佳实践。' },
{ d: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5" /><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3" /></>,
title: '数据孤岛', desc: '知识散落在文档、表格、聊天记录里,AI 答非所问,价值无法被激活。' },
{ d: <><path d="M3 3v18h18" /><path d="m19 9-5 5-4-4-3 3" /></>,
title: '个人开发者无变现渠道', desc: '做出好用的 AI 能力却找不到分发与交易场景,难以转化为收入。' },
];
const valueProps = [
{ d: <><path d="M12 1v22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
title: '低价订阅 · 无限 Token', kw: '无限Token',
desc: '一个低价会员即可放开使用,告别按次计费与额度焦虑,放心把 AI 用进日常。' },
{ d: <><rect x="3" y="3" width="7" height="7" rx="1.2" /><rect x="14" y="3" width="7" height="7" rx="1.2" /><rect x="3" y="14" width="7" height="7" rx="1.2" /><rect x="14" y="14" width="7" height="7" rx="1.2" /></>,
title: '垂直可用办公模板', kw: '办公提效',
desc: '写 PPT、出投标书、做运营、排版公众号——开箱即用的模板,把重复劳动交给 AI。' },
{ d: <><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" /><path d="M3 6h18" /><path d="M16 10a4 4 0 0 1-8 0" /></>,
title: '可交易的技能市场', kw: 'AI变现',
desc: '把自己打磨的技能上架交易,按订阅 / 下载获得收益,能力即资产。' },
{ d: <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /></>,
title: 'AI 创作试炼场', kw: '',
desc: '写小说、做漫剧、谱音乐——低成本试错,让创作想法快速跑通。' },
{ d: <><path d="M3 12a9 9 0 1 0 9-9" /><path d="M3 3v6h6" /><path d="M12 7v5l3 3" /></>,
title: '低成本养龙虾', kw: '',
desc: '低成本微调 / 托管你的专属模型,把"养模型"变成可负担的日常。' },
];
const features = [
{ d: <><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></>,
title: '多模型 AI 对话', desc: 'DeepSeek / Qwen / 国际模型自由切换,按需选用最优模型。' },
{ d: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></>,
title: '私有知识库', desc: '上传文档构建专属知识库,AI 回答基于你的内容,更精准可靠。' },
{ d: <><rect x="3" y="3" width="7" height="7" rx="1.2" /><rect x="14" y="3" width="7" height="7" rx="1.2" /><rect x="3" y="14" width="7" height="7" rx="1.2" /><rect x="14" y="14" width="7" height="7" rx="1.2" /></>,
title: '技能 / 应用市场', desc: '丰富的 Skills、Agent、MCP 应用,即装即用,一键接入工作流。' },
{ d: <><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>,
title: 'Token 驾驶舱', desc: '实时监控 Token 消耗,费用透明可控,每一笔都有据可查。' },
{ d: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>,
title: 'IM 频道绑定', desc: '微信 / 企微 / 飞书 / QQ 多平台绑定,AI 助手融入日常工作流。' },
{ d: <><polyline points="16 3 21 3 21 8" /><line x1="4" y1="20" x2="21" y2="3" /><polyline points="21 16 21 21 16 21" /><line x1="15" y1="15" x2="21" y2="21" /><line x1="4" y1="4" x2="9" y2="9" /></>,
title: '自动化任务编排', desc: '关键词触发、定时执行等自动化任务,让 AI 按你的节奏工作。' },
];
const LandingPage: React.FC = () => {
const navigate = useNavigate();
const [currentSlide, setCurrentSlide] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [mobileMenu, setMobileMenu] = useState(false);
const banners = mockAdBanners.filter(b => b.status === 'active');
const publishedApps = mockApps.filter(a => a.status === 'published');
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
const [toastMsg, setToastMsg] = useState('');
const topSkills = [...publishedApps].sort((a, b) => b.installCount - a.installCount).slice(0, 5);
const filteredApps = appTypeFilter
? publishedApps.filter(a => a.type === appTypeFilter)
: publishedApps;
const showToast = (msg: string) => {
setToastMsg(msg);
setTimeout(() => setToastMsg(''), 2000);
};
const handleAppClick = (appName: string) => {
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
setTimeout(() => navigate('/login'), 800);
};
// Auto rotate carousel
const nextSlide = useCallback(() => {
if (banners.length === 0) return;
setCurrentSlide(prev => (prev + 1) % banners.length);
@@ -44,323 +90,315 @@ const LandingPage: React.FC = () => {
useEffect(() => {
if (banners.length <= 1 || isPaused) return;
const timer = setInterval(nextSlide, 4000);
const timer = setInterval(nextSlide, 4500);
return () => clearInterval(timer);
}, [banners.length, isPaused, nextSlide]);
const goToSlide = (idx: number) => setCurrentSlide(idx);
// SEO / GEO:动态写入标题与描述
useEffect(() => {
document.title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式 PaaS 平台';
let meta = document.querySelector('meta[name="description"]');
if (!meta) { meta = document.createElement('meta'); meta.setAttribute('name', 'description'); document.head.appendChild(meta); }
meta.setAttribute('content', '七虎AI 是面向个人与职场用户的 AI PaaS 平台:低价订阅无限 Token、垂直办公模板、可交易技能市场、AI 创作试炼场与低成本养模型,让每个人用 AI 把事做成。');
}, []);
const getGradient = (app: MarketApp): string => {
const gradients: Record<string, string> = {
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
};
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
const goLogin = () => navigate('/login');
// 点击 logo 回到首页顶部(.lp 是滚动容器,用 scrollTo 而非 window)
const goTop = () => {
const el = document.querySelector('.lp') as HTMLElement | null;
if (el) el.scrollTo({ top: 0, behavior: 'smooth' });
else window.scrollTo({ top: 0, behavior: 'smooth' });
};
// HashRouter 下原生 <a href="#id"> 会被当成路由路径解析,导致菜单失效;
// 故改为编程式滚动 + preventDefault,仅在 LandingPage 内部滚动容器内跳转。
const anchorClick = (id: string, after?: () => void) => (e: React.MouseEvent) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
after?.();
};
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: '七虎AI',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.8', reviewCount: '1000' },
};
return (
<div className="landing-page">
{/* Toast notification */}
{toastMsg && (
<div className="landing-toast">{toastMsg}</div>
)}
<div className="lp">
{/* JSON-LD 富媒体摘要(SEO / GEO) */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* Hero / Nav bar */}
<header className="landing-header">
<div className="landing-header-inner">
<div className="landing-brand">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
{/* 浮层玻璃顶栏 */}
<header className="lp-header glass">
<div className="lp-header-inner">
<div className="lp-brand" onClick={goTop} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && goTop()} aria-label="回到顶部">
<svg width="30" height="30" viewBox="0 0 32 32" fill="none" aria-hidden>
<rect width="32" height="32" rx="8" fill="#5B5FED" />
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<path d="M6 22h20" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.5" />
</svg>
<span className="brand-name">Qihu-AIFlow</span>
<span className="brand-name">七虎AI</span>
</div>
<nav className="landing-nav-links">
<a href="#market">应用市场</a>
<a href="#features">平台能力</a>
<nav className="lp-nav" aria-label="主导航">
<a href="#positioning" onClick={anchorClick('positioning')}>平台定位</a>
<a href="#value" onClick={anchorClick('value')}>平台价值</a>
<a href="#top" onClick={anchorClick('top')}>热门技能</a>
<a href="#market" onClick={anchorClick('market')}>应用市场</a>
<a href="#features" onClick={anchorClick('features')}>平台能力</a>
</nav>
<div className="landing-header-actions">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
<div className="lp-header-actions">
<button className="lp-btn lp-btn-ghost" onClick={goLogin}>登录</button>
<button className="lp-btn lp-btn-primary" onClick={goLogin}>免费注册</button>
</div>
<button className="lp-menu-toggle" aria-label="菜单" onClick={() => setMobileMenu(v => !v)}>
<Icon d={mobileMenu ? <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></> : <><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></>} size={22} />
</button>
</div>
{mobileMenu && (
<div className="lp-mobile-menu">
<a href="#positioning" onClick={anchorClick('positioning', () => setMobileMenu(false))}>平台定位</a>
<a href="#value" onClick={anchorClick('value', () => setMobileMenu(false))}>平台价值</a>
<a href="#top" onClick={anchorClick('top', () => setMobileMenu(false))}>热门技能</a>
<a href="#market" onClick={anchorClick('market', () => setMobileMenu(false))}>应用市场</a>
<a href="#features" onClick={anchorClick('features', () => setMobileMenu(false))}>平台能力</a>
<div className="lp-mobile-actions">
<button className="lp-btn lp-btn-ghost" onClick={() => { setMobileMenu(false); goLogin(); }}>登录</button>
<button className="lp-btn lp-btn-primary" onClick={() => { setMobileMenu(false); goLogin(); }}>免费注册</button>
</div>
</div>
)}
</header>
{/* Hero Section */}
<section className="landing-hero">
<div className="landing-hero-content">
<h1 className="hero-title">
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
</h1>
<p className="hero-desc">
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
让每个人都能轻松搭建属于自己的AI自动化SOP。
</p>
<div className="hero-actions">
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费开始使用
</button>
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
浏览应用市场
</button>
</div>
<div className="hero-stats">
<div className="hero-stat">
<span className="stat-number">3,500+</span>
<span className="stat-label">活跃用户</span>
<main>
{/* Hero */}
<section className="lp-hero">
<div className="lp-hero-grid">
<div className="lp-hero-text">
<div className="lp-eyebrow">AI PaaS 平台</div>
<h1 className="lp-title">让每个人都能<br /><span className="lp-accent">用 AI 把事做成</span></h1>
<p className="lp-desc">
七虎AI 是个人体验 AI、办公提效、AI 创收与自我提升的一站式交互平台。
低价订阅 <span className="kw">无限Token</span>,提供垂直可用的办公模板、可交易的市场与低成本养模型能力。
</p>
<div className="lp-actions">
<button className="lp-btn lp-btn-primary lp-btn-lg" onClick={goLogin}>免费开始使用</button>
<button className="lp-btn lp-btn-outline lp-btn-lg" onClick={anchorClick('market')}>浏览应用市场</button>
</div>
<div className="lp-stats">
<div><span className="lp-stat-num">3,500+</span><span className="lp-stat-label">活跃用户</span></div>
<div><span className="lp-stat-num">120+</span><span className="lp-stat-label">应用 / Skills</span></div>
<div><span className="lp-stat-num">99.8%</span><span className="lp-stat-label">服务可用率</span></div>
</div>
</div>
<div className="hero-stat">
<span className="stat-number">120+</span>
<span className="stat-label">应用/Skills</span>
</div>
<div className="hero-stat">
<span className="stat-number">99.8%</span>
<span className="stat-label">服务可用率</span>
</div>
</div>
</div>
</section>
{/* Carousel Section */}
{banners.length > 0 && (
<section className="landing-banners">
<div className="landing-section-inner">
<h2 className="section-title">推荐应用</h2>
<div
className="carousel-container"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
{banners.map(banner => (
<div key={banner.id} className="carousel-slide">
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<div className="carousel-card-content">
<div className="carousel-badge">推荐</div>
<h3 className="carousel-title">{banner.title}</h3>
{banner.linkAppName && (
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
)}
<button
className="carousel-cta"
onClick={() => navigate('/login')}
>
查看详情
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
<div className="carousel-card-visual">
<div className="visual-circle c1"/>
<div className="visual-circle c2"/>
<div className="visual-circle c3"/>
</div>
</div>
</div>
))}
{/* Hero 右侧:品牌渐变光斑 + 玻璃卡视觉 */}
<div className="lp-hero-visual" aria-hidden>
<div className="lp-hero-glow" />
<div className="lp-hero-card glass">
<div className="lp-hc-row"><span className="lp-hc-dot" /> AI 对话进行中</div>
<div className="lp-hc-bubble">帮我把这份文档整理成周报大纲</div>
<div className="lp-hc-bubble lp-hc-bubble-ai">已生成 5 个章节,并附数据看板建议 ✓</div>
<div className="lp-hc-bar"><span /></div>
</div>
{/* Dots */}
{banners.length > 1 && (
<div className="carousel-dots">
{banners.map((_, idx) => (
<button
key={idx}
className={`carousel-dot ${idx === currentSlide ? 'active' : ''}`}
onClick={() => goToSlide(idx)}
/>
))}
</div>
)}
{/* Arrows */}
{banners.length > 1 && (
<>
<button
className="carousel-arrow carousel-arrow-left"
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
>
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
</button>
<button
className="carousel-arrow carousel-arrow-right"
onClick={() => nextSlide()}
>
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</>
)}
</div>
</div>
</section>
)}
{/* Market Preview Section */}
<section className="landing-market" id="market">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">应用市场</h2>
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
{/* 轮播广告位(技能展示) */}
{banners.length > 0 && (
<section className="lp-band">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">精选推荐</h2>
<p className="lp-sub">平台热门技能广告位,提升展现与下载</p>
</div>
<div className="lp-carousel" onMouseEnter={() => setIsPaused(true)} onMouseLeave={() => setIsPaused(false)}>
<div className="lp-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
{banners.map(b => (
<div key={b.id} className="lp-slide">
<div className="lp-carousel-card" style={{ background: gradFor(b.linkAppId || 'a1') }}>
<div className="lp-cc-content">
<div className="lp-cc-badge">推荐</div>
<h3 className="lp-cc-title">{b.title}</h3>
{b.linkAppName && <p className="lp-cc-sub">立即体验 {b.linkAppName}</p>}
<button className="lp-cc-cta" onClick={goLogin}>查看详情 <Icon d={<polyline points="9 18 15 12 9 6" />} size={14} /></button>
</div>
<div className="lp-cc-visual"><span className="c1" /><span className="c2" /><span className="c3" /></div>
</div>
</div>
))}
</div>
{banners.length > 1 && (
<>
<button className="lp-arrow lp-arrow-l" onClick={() => setCurrentSlide(p => (p - 1 + banners.length) % banners.length)} aria-label="上一张"><Icon d={<polyline points="15 18 9 12 15 6" />} size={20} /></button>
<button className="lp-arrow lp-arrow-r" onClick={nextSlide} aria-label="下一张"><Icon d={<polyline points="9 18 15 12 9 6" />} size={20} /></button>
<div className="lp-dots">{banners.map((_, i) => (<button key={i} className={`lp-dot ${i === currentSlide ? 'on' : ''}`} onClick={() => setCurrentSlide(i)} aria-label={`第${i + 1}张`} />))}</div>
</>
)}
</div>
</div>
</section>
)}
{/* 平台定位(痛点) */}
<section className="lp-section" id="positioning">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">我们解决什么</h2>
<p className="lp-sub">七虎AI 帮你绕过个人与职场使用 AI 时最高频的痛点</p>
</div>
<div className="lp-grid lp-grid-4">
{painPoints.map((p, i) => (
<article key={i} className="lp-pain">
<div className="lp-pain-icon"><Icon d={p.d} size={24} /></div>
<h3 className="lp-pain-title">{p.title}</h3>
<p className="lp-pain-desc">{p.desc}</p>
</article>
))}
</div>
</div>
</section>
{/* 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>
{/* 平台价值 */}
<section className="lp-section lp-section-alt" id="value">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">在七虎AI 你能获得</h2>
<p className="lp-sub">降低订阅成本、提供无限 Token、垂直模板与可交易市场</p>
</div>
<div className="lp-grid lp-grid-5">
{valueProps.map((v, i) => (
<article key={i} className="lp-value">
<div className="lp-value-icon"><Icon d={v.d} size={24} /></div>
<h3 className="lp-value-title">{v.kw ? <>{v.title.replace(v.kw, '')}<span className="kw"> {v.kw}</span></> : v.title}</h3>
<p className="lp-value-desc">{v.desc}</p>
</article>
))}
</div>
</div>
</section>
{/* 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]}
{/* 热门技能榜 Top5 */}
<section className="lp-section" id="top">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">热门技能榜 · Top 5</h2>
<p className="lp-sub">按下载 / 安装量排行,看看大家都在用哪些能力</p>
</div>
<ol className="lp-rank">
{topSkills.map((app, i) => (
<li key={app.id} className="lp-rank-item" onClick={goLogin} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && goLogin()}>
<span className={`lp-rank-no ${i === 0 ? 'top' : ''}`}>{i + 1}</span>
<span className="lp-rank-cover" style={{ background: gradFor(app.id) }}>
<span className={`lp-rank-badge ${typeBadge[app.type]}`}>{typeLabels[app.type]}</span>
</span>
{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>
<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>
</section>
{/* 应用市场预览 */}
<section className="lp-section lp-section-alt" id="market">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">应用市场</h2>
<p className="lp-sub">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
</div>
<div className="lp-app-grid">
{publishedApps.map(app => (
<article key={app.id} className="lp-app-card" onClick={goLogin} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && goLogin()}>
<div className="lp-app-cover" style={{ background: gradFor(app.id) }}>
<span className={`lp-app-badge ${typeBadge[app.type]}`}>{typeLabels[app.type]}</span>
{app.price > 0 && <span className="lp-app-price">{app.price} 元</span>}
</div>
</div>
</div>
))}
</div>
<div className="market-more">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
登录查看更多应用
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
</div>
</section>
{/* Features Section */}
<section className="landing-features" id="features">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">平台核心能力</h2>
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
</div>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
</div>
<h3 className="feature-title">多模型AI对话</h3>
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
<div className="lp-app-body">
<h4 className="lp-app-name">{app.name}</h4>
<p className="lp-app-desc">{app.description}</p>
<div className="lp-app-meta">
<span><Icon d={<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />} size={12} /> {app.ratingAvg > 0 ? app.ratingAvg : '-'}</span>
<span>{app.installCount.toLocaleString()} 次安装</span>
</div>
</div>
</article>
))}
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon>
</div>
<h3 className="feature-title">私有知识库</h3>
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon>
</div>
<h3 className="feature-title">应用市场</h3>
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
</div>
<h3 className="feature-title">Token驾驶舱</h3>
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon>
</div>
<h3 className="feature-title">IM频道绑定</h3>
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon>
</div>
<h3 className="feature-title">自动化任务编排</h3>
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
<div 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>
</section>
</section>
{/* CTA Section */}
<section className="landing-cta">
<div className="landing-section-inner">
<h2 className="cta-title">准备好开始了吗?</h2>
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费注册,立即体验
</button>
</div>
</section>
{/* 平台能力 */}
<section className="lp-section" id="features">
<div className="lp-inner">
<div className="lp-section-head">
<h2 className="lp-h2">平台核心能力</h2>
<p className="lp-sub">从对话到自动化,七虎AI 提供完整的 AI 工作流解决方案</p>
</div>
<div className="lp-grid lp-grid-3">
{features.map((f, i) => (
<article key={i} className="lp-feature">
<div className="lp-feature-icon"><Icon d={f.d} size={26} /></div>
<h3 className="lp-feature-title">{f.title}</h3>
<p className="lp-feature-desc">{f.desc}</p>
</article>
))}
</div>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-section-inner">
<div className="footer-content">
<div className="footer-brand">
<div className="landing-brand">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
{/* CTA */}
<section className="lp-cta">
<div className="lp-inner">
<h2 className="lp-cta-title">准备好开始了吗?</h2>
<p className="lp-cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
<button className="lp-btn lp-btn-primary lp-btn-lg" onClick={goLogin}>免费注册,立即体验</button>
</div>
</section>
</main>
{/* Footer(SEO 链接 + 富媒体) */}
<footer className="lp-footer">
<div className="lp-inner">
<div className="lp-footer-grid">
<div className="lp-footer-brand">
<div className="lp-brand" onClick={goTop} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && goTop()} aria-label="回到顶部">
<svg width="26" height="26" viewBox="0 0 32 32" fill="none" aria-hidden>
<rect width="32" height="32" rx="8" fill="#5B5FED" />
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
</div>
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
AI智能自动化PaaS平台
</p>
</div>
<div className="footer-links">
<div className="footer-col">
<h4>产品</h4>
<a>AI聊天</a>
<a>知识库</a>
<a>应用市场</a>
</div>
<div className="footer-col">
<h4>支持</h4>
<a>帮助文档</a>
<a>API参考</a>
<a>联系我们</a>
<span className="brand-name">七虎AI</span>
</div>
<p>个人体验 AI、办公提效与 AI 创收的一站式 PaaS 平台。</p>
</div>
<div className="lp-footer-col"><h4>产品</h4><a href="#features" onClick={anchorClick('features')}>AI 聊天</a><a href="#market" onClick={anchorClick('market')}>应用市场</a><a href="#value" onClick={anchorClick('value')}>技能交易</a></div>
<div className="lp-footer-col"><h4>资源</h4><a href="#top" onClick={anchorClick('top')}>热门技能</a><a href="#positioning" onClick={anchorClick('positioning')}>平台定位</a><a onClick={goLogin} role="button">帮助中心</a></div>
<div className="lp-footer-col"><h4>公司</h4><a onClick={goLogin} role="button">关于我们</a><a onClick={goLogin} role="button">联系我们</a><a onClick={goLogin} role="button">加入我们</a></div>
</div>
<div className="footer-bottom">
<span>Qihu-AIFlow 2026. All rights reserved.</span>
</div>
<div className="lp-footer-bottom">© 2026 七虎AI · 让每个人都能用 AI 把事做成</div>
</div>
</footer>
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -209,7 +209,7 @@ const PricingPage: React.FC = () => {
setShowModal(null);
// 跳转到聊天页面,带定制服务ID
setTimeout(() => {
window.location.hash = '#/chat?service_id=' + res.data.id;
window.location.href = '/chat?service_id=' + res.data.id;
}, 1500);
}
} catch (e: any) {
+3 -3
View File
@@ -678,8 +678,8 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
</div>
<div className="membership-actions">
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
<button className="btn-primary" onClick={() => { window.location.href = '/pricing'; }}>续费会员</button>
<button className="btn-secondary" onClick={() => { window.location.href = '/pricing'; }}>升级会员</button>
</div>
</>
)}
@@ -692,7 +692,7 @@ const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.location.hash = '#/pricing';
window.location.href = '/pricing';
}}
>
开通会员