diff --git a/frontend/prerender.js b/frontend/prerender.js
new file mode 100644
index 0000000..13f0b4c
--- /dev/null
+++ b/frontend/prerender.js
@@ -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 = '
';
+if (html.includes(rootOpen)) {
+ html = html.replace(rootOpen, '' + ssrHtml + '
');
+} else {
+ html = html.replace(/[\s\S]*?<\/div>\s*<\/body>/,
+ '
' + ssrHtml + '
');
+}
+
+// 4) 注入 SEO/GEO
元数据(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}`,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+].join('\n');
+html = html.replace('', headInject + '\n ');
+
+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 = '\n' +
+ '\n' +
+ urls.map(u => ` ${u}daily1.0`).join('\n') +
+ '\n\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');
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index c1d222f..d5887ed 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -38,7 +38,7 @@ export async function request(
// 其他接口的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(`/chat/conversations/${id}`),
getMessages: (convId: string) =>
get(`/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(`/chat/conversations/${convId}/messages`, data),
clearContext: (convId: string) =>
post(`/chat/conversations/${convId}/clear`),
convertToKnowledgeGraph: (convId: string) =>
- post(`/chat/conversations/${convId}/to-knowledge-graph`),
+ post(`/chat/conversations/${convId}/to-knowledge-graph/`),
getModels: () => get('/chat/models'),
};
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
deleteBase: (id: string) => del(`/knowledge/bases/${id}`),
getDocuments: (baseId: string) => get(`/knowledge/bases/${baseId}/documents`),
getDocument: (baseId: string, docId: string) => get(`/knowledge/bases/${baseId}/documents/${docId}`),
- uploadDocument: (baseId: string, data: any) => post(`/knowledge/bases/${baseId}/documents`, data),
+ uploadDocument: (baseId: string, data: any) => post(`/knowledge/bases/${baseId}/documents/`, data),
deleteDocument: (baseId: string, docId: string) => del(`/knowledge/bases/${baseId}/documents/${docId}`),
getDirectories: () => get('/knowledge/directories'),
createDirectory: (data: any) => post('/knowledge/directories', data),
@@ -315,11 +315,6 @@ export const adminAPI = {
updateAdminModel: (id: string, data: any) => put(`/admin/models/${id}`, data),
deleteAdminModel: (id: string) => del(`/admin/models/${id}`),
getModelStats: () => get('/admin/models/stats'),
- // 能力类型 & 按能力默认模型
- getCapabilities: () => get('/admin/models/capabilities'),
- getCapabilityDefaults: () => get('/admin/models/capability-defaults'),
- setCapabilityDefault: (capability: string, modelId: string | null) =>
- put('/admin/models/capability-defaults', { capability, model_id: modelId }),
// 网站设置
getSiteSettings: () => get('/admin/settings/site'),
updateSiteSettings: (data: any) => put('/admin/settings/site', data),
@@ -724,16 +719,7 @@ export const adminAPI = {
getTenantMembers: (tenantId: string) => get(`/admin/tenant/${tenantId}/members`),
createTenant: (params: string) => post(`/admin/tenant/create${params}`),
-
- // ---- 以下 4 个方法为 1346fbb 之后新增, 保留 ----
- toggleAd: (id: string) => put(`/admin/ads/${id}/toggle`),
- getAdminApps: () => get('/admin/market/apps'),
- updateApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data),
- assignCoupon: (id: string, userId: string) => post(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
-
};
-
-
// ===== AI自进化API =====
export const evolutionAPI = {
// 驾驶舱面板
@@ -803,9 +789,212 @@ export const evolutionAPI = {
return get(`/evolution/reports${qs}`);
},
getReportDetail: (reportId: string) => get(`/evolution/reports/${reportId}`),
+
+
+ // Workflow (Phase 3.6)
+ getWorkflows: (params?: Record) =>
+ get('/admin/workflows/?' + new URLSearchParams(params || {}).toString()),
+ getWorkflow: (id: string) =>
+ get(`/admin/workflows/${id}`),
+ getWorkflowStats: () =>
+ get('/admin/workflows/stats'),
+ getWorkflowTemplates: () =>
+ get('/admin/workflows/templates'),
+ createWorkflow: (data: any) =>
+ post('/admin/workflows/', data),
+ updateWorkflow: (id: string, data: any) =>
+ put(`/admin/workflows/${id}`, data),
+ deleteWorkflow: (id: string) =>
+ del(`/admin/workflows/${id}`),
+ toggleWorkflow: (id: string, isActive: boolean) =>
+ post(`/admin/workflows/${id}/toggle?is_active=${isActive}`),
+ executeWorkflow: (id: string, inputText: string) =>
+ post(`/admin/workflows/${id}/execute`, { input_text: inputText }),
+
+
+ // Privacy & Local Agent (Phase G5)
+ getPrivacyStats: () => get('/admin/privacy/stats'),
+ getPrivacyConfig: () => get('/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('/admin/privacy/config?' + sp.toString());
+ },
+ switchPrivacyMode: (mode: string) => post('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)),
+ getPrivacyLicenses: (params?: any) => {
+ const sp = new URLSearchParams();
+ if (params?.status) sp.append('status', params.status);
+ return get('/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('/admin/privacy/licenses/generate?' + sp.toString());
+ },
+ revokePrivacyLicense: (id: number) => post('/admin/privacy/licenses/' + id + '/revoke'),
+ validatePrivacyLicense: (licenseKey: string) => {
+ const sp = new URLSearchParams();
+ sp.append('license_key', licenseKey);
+ return post('/admin/privacy/licenses/validate?' + sp.toString());
+ },
+ getKbSyncStates: () => get('/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('/admin/privacy/kb-sync/trigger?' + sp.toString());
+ },
+ getLocalAgents: () => get('/admin/privacy/local-agents'),
+ getPrivacyUsageStats: () => get('/admin/privacy/usage/stats'),
+ getBridgeConfig: () => get('/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('/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(`/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(`/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(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`);
+ },
+ deletePromotion: (id: string) => del(`/admin/promotion/promotions/${id}`),
+ togglePromotion: (id: string) => post(`/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(`/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(`/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(`/admin/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`);
+ },
+ approveReward: (id: string) => post(`/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(`/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(`/admin/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`);
+ },
+ regenerateReferralCode: (id: string) => post(`/admin/promotion/referral-links/${id}/regenerate`),
+ getMyReferrals: () => get('/admin/promotion/my-referrals'),
+ getMyRewards: () => get('/admin/promotion/my-rewards'),
+
+ // 成就系统
+ getAchievements: (params?: string) => get(`/admin/achievement/achievements${params || ''}`),
+ getAchievementDetail: (id: string) => get(`/admin/achievement/achievements/${id}`),
+ createAchievement: (params: string) => post(`/admin/achievement/achievements${params}`),
+ updateAchievement: (id: string, params: string) => put(`/admin/achievement/achievements/${id}${params}`),
+ deleteAchievement: (id: string) => del(`/admin/achievement/achievements/${id}`),
+ getUserAchievements: () => get('/admin/achievement/user/achievements'),
+ claimAchievementReward: (id: string) => post(`/admin/achievement/user/achievements/${id}/claim`),
+ checkAchievementProgress: () => post('/admin/achievement/check-progress'),
+ getAchievementLeaderboard: (params?: string) => get(`/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(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
+ },
+ createTenant: (params: string) => post(`/admin/tenant/create${params}`),
+ getTenantDetail: (id: string) => get(`/admin/tenant/${id}`),
+ updateTenant: (id: string, params: string) => put(`/admin/tenant/${id}${params}`),
+ deleteTenant: (id: string) => del(`/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(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
+ },
+ addTenantMember: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/members/add${params}`),
+ updateTenantMember: (tenantId: string, memberId: string, params: string) => put(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
+ removeTenantMember: (tenantId: string, memberId: string) => del(`/admin/tenant/${tenantId}/members/${memberId}`),
+ getTenantQuotas: (tenantId: string) => get(`/admin/tenant/${tenantId}/quotas`),
+ updateTenantQuota: (tenantId: string, params: string) => put(`/admin/tenant/${tenantId}/quotas${params}`),
+ getTenantInvitations: (tenantId: string) => get(`/admin/tenant/${tenantId}/invitations`),
+ createTenantInvitation: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/invitations${params}`),
+ getTenantStats: () => get('/admin/tenant/stats/overview'),
+
+ // 计费与支付
+ getBillingRules: (page?: number) => get(`/admin/billing/rules?page=${page || 1}&page_size=100`),
+ createBillingRule: (params: string) => post(`/admin/billing/rules/create${params}`),
+ updateBillingRule: (id: string, params: string) => put(`/admin/billing/rules/${id}${params}`),
+ deleteBillingRule: (id: string) => del(`/admin/billing/rules/${id}`),
+ getBillingPackages: () => get('/admin/billing/packages'),
+ createBillingPackage: (params: string) => post(`/admin/billing/packages/create${params}`),
+ updateBillingPackage: (id: string, params: string) => put(`/admin/billing/packages/${id}${params}`),
+ deleteBillingPackage: (id: string) => del(`/admin/billing/packages/${id}`),
+ getBillingTransactions: (page?: number) => get(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
+ getInvoices: (page?: number) => get(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
+ generateInvoice: (params: string) => post(`/admin/billing/invoices/generate${params}`),
+ getPaymentConfigs: () => get('/admin/billing/payment-configs'),
+ updatePaymentConfig: (id: string, params: string) => put(`/admin/billing/payment-configs/${id}${params}`),
+ getBillingStats: () => get('/admin/billing/stats/overview'),
+
+ // 数据分析
+ getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview?days=${days || 30}`),
+ getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls?days=${days || 7}`),
+ getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens?days=${days || 7}`),
+ getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users?days=${days || 30}`),
+ getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue?days=${days || 30}`),
+ getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage?days=${days || 30}`),
+ getAnalyticsDashboards: () => get('/admin/analytics/dashboards'),
+ createAnalyticsDashboard: (params: string) => post(`/admin/analytics/dashboards/create${params}`),
+ deleteAnalyticsDashboard: (id: string) => del(`/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(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
+ },
+
};
-// ===== iLink 个人微信(前台用户) =====
+
+// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
export const imUserAPI = {
getStatus: () => get('/im/wechat/status'),
qrcode: () => post('/im/wechat/qrcode'),
diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx
index 36bcb8c..5982037 100644
--- a/frontend/src/components/layout/AdminLayout.tsx
+++ b/frontend/src/components/layout/AdminLayout.tsx
@@ -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 = {
- dashboard: ,
- users: ,
- models: ,
- settings: ,
- payment: ,
- services: ,
- membership: ,
- ads: ,
- market: ,
- logs: ,
-};
-
-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 (
-
- {/* Admin Sidebar */}
-
-
- {/* Admin Main Content */}
-
-
-
- 欢迎,{user.username}
-
-
-
-
- 返回前台
-
-
-
- {user.username.charAt(0).toUpperCase()}
-
-
{user.username}
-
-
-
-
-
-
-
-
- );
-};
-
-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 = {
+ dashboard: ,
+ users: ,
+ models: ,
+ settings: ,
+ payment: ,
+ services: ,
+ membership: ,
+ ads: ,
+ market: ,
+ logs: ,
+ agents: ,
+ skills: ,
+ code: ,
+ distro: ,
+ privacy: ,
+ promotion: ,
+ achievement: ,
+ tenant: ,
+ billing: ,
+ analytics: ,
+ imbot: ,
+ storage: ,
+ automation: ,
+ developer: ,
+ devtools: ,
+ knowledge: ,
+ workflow: ,
+ mcp: ,
+ apps: ,
+ assets: ,
+ chevron: ,
+};
+
+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>(() => {
+ const initial = new Set();
+ 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 (
+
+
+
+
+
+
+ );
+};
+
+export default AdminLayout;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 08af992..43c906c 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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(
-
+
-
+
);
diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx
index 089ebbc..d4c96bd 100644
--- a/frontend/src/pages/admin/Dashboard.tsx
+++ b/frontend/src/pages/admin/Dashboard.tsx
@@ -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 }) => (
-
-);
-
-const quickEntries = [
- { path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: },
- { path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: },
- { path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: },
- { path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: },
- { path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: },
- { path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: },
- { path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: },
-];
-
-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([]);
- const [auditLogs, setAuditLogs] = useState([]);
- 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: },
- { label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: },
- { label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: },
- { label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: },
- ];
-
- if (loading) {
- return (
-
- );
- }
-
- return (
-
-
平台驾驶舱
-
- {/* Stats Cards */}
-
- {statCards.map((s, i) => (
-
-
- {s.icon}
-
-
-
{s.label}
-
{s.value}
-
-
- ))}
-
-
- {/* Platform Cumulative Token Stats */}
-
-
-
-
- {' '}平台累计Token消耗
-
-
-
- {[
- { 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) => (
-
-
- {item.value}
-
-
- {item.label}
-
-
{item.unit}
-
- ))}
-
-
-
- {/* Quick Entry Cards */}
-
- {quickEntries.map((entry) => (
-
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';
- }}
- >
-
{entry.icon}
-
{entry.label}
-
{entry.desc}
-
- ))}
-
-
- {/* Recent Operation Logs */}
-
-
-
最近操作日志
-
-
-
-
-
- | 用户 |
- 操作 |
- 目标 |
- 详情 |
- IP |
- 时间 |
-
-
-
- {operationLogs.length > 0 ? operationLogs.map(log => (
-
- | {log.username} |
- {log.action} |
- {log.target} |
- {log.detail} |
- {log.ip} |
- {formatDate(log.created_at)} |
-
- )) : (
- | 暂无操作日志 |
- )}
-
-
-
-
-
- {/* Recent Audit Logs */}
-
-
-
最近审计日志
-
-
-
-
-
- | 操作人 |
- 操作类型 |
- 目标类型 |
- 详情 |
- 时间 |
-
-
-
- {auditLogs.length > 0 ? auditLogs.map(log => (
-
- | {log.admin_name} |
- {log.action} |
- {log.target_type} |
- {log.detail} |
- {formatDate(log.created_at)} |
-
- )) : (
- | 暂无审计日志 |
- )}
-
-
-
-
-
- );
-};
-
-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 }) => (
+
+);
+
+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: },
+ { path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: },
+ { path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: },
+ { path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: },
+ { path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: },
+ { path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: },
+ { path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: },
+];
+
+// SVG Bar Chart Component
+const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
+ if (!data || data.length === 0) return 暂无数据
;
+
+ 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 (
+
+ );
+};
+
+// 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 (
+
+ );
+};
+
+const Dashboard: React.FC = () => {
+ const navigate = useNavigate();
+ const [stats, setStats] = useState({
+ 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([]);
+ const [operationLogs, setOperationLogs] = useState([]);
+ const [auditLogs, setAuditLogs] = useState([]);
+ 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: },
+ { 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: },
+ { 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: },
+ { 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: },
+ { 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: },
+ { 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: },
+ ];
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
平台驾驶舱
+
+ {/* Stats Cards Row */}
+
+ {statCardDefs.map((s, i) => (
+
+
+ {s.icon}
+
+
+
{s.label}
+
+ {s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
+
+
{s.sub}
+
+
+ ))}
+
+
+ {/* Charts Row */}
+
+ {/* Token Trend Chart */}
+
+
+
+
+ {' '}Token消耗趋势(近30天)
+
+
+
+
+
+
+
+ {/* System Health */}
+
+
+
+ {[
+ { 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) => (
+
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+ 服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'}
+
+
+
+
+
+ {/* Quick Actions */}
+
+
快捷入口
+
+ {quickEntries.map((entry) => (
+
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';
+ }}
+ >
+
{entry.icon}
+
+
{entry.label}
+
{entry.desc}
+
+
+ ))}
+
+
+
+ {/* Recent Logs */}
+
+
+
最近操作日志
+ 最近10条
+
+
+
+
+
+ | 用户 |
+ 操作 |
+ 目标 |
+ 详情 |
+ IP |
+ 时间 |
+
+
+
+ {operationLogs.length > 0 ? operationLogs.map(log => (
+
+ | {log.username} |
+ {log.action} |
+ {log.target} |
+ {log.detail} |
+ {log.ip} |
+ {formatDate(log.created_at)} |
+
+ )) : (
+ | 暂无操作日志 |
+ )}
+
+
+
+
+
+ {/* Recent Audit Logs */}
+
+
+
最近审计日志
+ 最近10条
+
+
+
+
+
+ | 操作人 |
+ 操作类型 |
+ 目标类型 |
+ 详情 |
+ 时间 |
+
+
+
+ {auditLogs.length > 0 ? auditLogs.map(log => (
+
+ | {log.admin_name} |
+ {log.action} |
+ {log.target_type} |
+ {log.detail} |
+ {formatDate(log.created_at)} |
+
+ )) : (
+ | 暂无审计日志 |
+ )}
+
+
+
+
+
+ );
+};
+
+export default Dashboard;
diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx
index a31818d..1764f34 100644
--- a/frontend/src/pages/front/ChatPage.tsx
+++ b/frontend/src/pages/front/ChatPage.tsx
@@ -1,1080 +1,1135 @@
-import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
-import type { Conversation, Message } from '@/types';
-import './ChatPage.css';
-
-// ===== Simple Markdown Renderer =====
-const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
- const html = useMemo(() => {
- let text = content;
- // Code blocks (```)
- text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => {
- return `${escapeHtml(code.trim())}
`;
- });
- // Inline code (`)
- text = text.replace(/`([^`]+)`/g, '$1');
- // Bold (**text**)
- text = text.replace(/\*\*(.+?)\*\*/g, '$1');
- // Italic (*text*)
- text = text.replace(/\*(.+?)\*/g, '$1');
- // Headers (## heading)
- text = text.replace(/^### (.+)$/gm, '$1
');
- text = text.replace(/^## (.+)$/gm, '$1
');
- text = text.replace(/^# (.+)$/gm, '$1
');
- // Unordered list items
- text = text.replace(/^- (.+)$/gm, '$1');
- // Ordered list items
- text = text.replace(/^\d+\. (.+)$/gm, '$1');
- // Horizontal rule
- text = text.replace(/^---$/gm, '
');
- // Paragraph spacing: double newlines
- text = text.replace(/\n\n/g, '
');
- // Single newlines to
- text = text.replace(/\n/g, '
');
- return text;
- }, [content]);
-
- return ;
-};
-
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
-// ===== Time Formatting =====
-function formatTime(iso: string): string {
- const date = new Date(iso);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const oneDay = 24 * 60 * 60 * 1000;
-
- if (diff < oneDay) {
- return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
- } else if (diff < 7 * oneDay) {
- const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
- return days[date.getDay()];
- } else if (date.getFullYear() === now.getFullYear()) {
- return `${date.getMonth() + 1}/${date.getDate()}`;
- } else {
- return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
- }
-}
-
-// ===== Helpers: normalize API responses =====
-function unwrapList(res: any): any[] {
- const data = res?.data !== undefined ? res.data : res;
- if (Array.isArray(data)) return data;
- if (data?.items) return data.items;
- return [];
-}
-
-function unwrapData(res: any): any {
- return res?.data !== undefined ? res.data : res;
-}
-
-// ===== Icons =====
-const IconSearch = () => (
-
-);
-
-const IconPlus = () => (
-
-);
-
-const IconMore = () => (
-
-);
-
-const IconSend = () => (
-
-);
-
-const IconChevronDown = () => (
-
-);
-
-const IconEdit = () => (
-
-);
-
-const IconArchive = () => (
-
-);
-
-const IconTrash = () => (
-
-);
-
-const IconPin = () => (
-
-);
-
-const IconGraph = () => (
-
-);
-
-const IconKnowledge = () => (
-
-);
-
-const IconAgent = () => (
-
-);
-
-const IconSkill = () => (
-
-);
-
-const IconModel = () => (
-
-);
-
-const IconEmpty = () => (
-
-);
-
-const IconSpinner = () => (
-
-);
-
-// ===== Dropdown Component =====
-interface DropdownProps {
- trigger: React.ReactNode;
- children: React.ReactNode;
- className?: string;
- align?: 'left' | 'right';
- dropup?: boolean;
- closeOnClick?: boolean;
-}
-
-const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const menuRef = useRef(null);
- const openRef = useRef(open);
- openRef.current = open;
-
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- const target = e.target as Node;
- // 如果点击在wrapper内部,不关闭
- if (ref.current?.contains(target)) return;
- setOpen(false);
- };
- document.addEventListener('mousedown', handleClickOutside, true);
- return () => document.removeEventListener('mousedown', handleClickOutside, true);
- }, []);
-
- // 打开时处理z-index
- useEffect(() => {
- if (open && dropup) {
- const menu = menuRef.current;
- if (menu) {
- menu.style.zIndex = '9999';
- }
- }
- }, [open, dropup]);
-
- const alignClass = align === 'right' ? 'align-right' : 'align-left';
- const dropupClass = dropup ? 'dropup' : '';
-
- const handleTriggerMouseDown = (e: React.MouseEvent) => {
- // 阻止冒泡到document,防止被handleClickOutside误关
- e.stopPropagation();
- // 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
- setOpen(prev => !prev);
- };
-
- const handleTriggerClick = (e: React.MouseEvent) => {
- // 阻止click冒泡到父级的导航onClick
- e.stopPropagation();
- e.preventDefault();
- };
-
- return (
-
-
- {trigger}
-
- {open && (
-
e.stopPropagation()}
- onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
- >
- {children}
-
- )}
-
- );
-};
-
-// ===== Main Component =====
-const ChatPage: React.FC = () => {
- const { id } = useParams<{ id?: string }>();
- const navigate = useNavigate();
- const [searchQuery, setSearchQuery] = useState('');
- const [selectedModelId, setSelectedModelId] = useState('');
- const [chatModels, setChatModels] = useState([]);
- const [inputValue, setInputValue] = useState('');
- const [sending, setSending] = useState(false);
- const [conversations, setConversations] = useState([]);
- const [messages, setMessages] = useState([]);
- const [loadingConversations, setLoadingConversations] = useState(true);
- const [loadingMessages, setLoadingMessages] = useState(false);
- const [knowledgeBases, setKnowledgeBases] = useState([]);
- const [agents, setAgents] = useState([]);
- const [skills, setSkills] = useState([]);
- // 多选组合模式
- const [selectedAgentIds, setSelectedAgentIds] = useState([]);
- const [selectedSkillIds, setSelectedSkillIds] = useState([]);
- const [selectedKbIds, setSelectedKbIds] = useState([]);
- const messagesEndRef = useRef(null);
- const inputRef = useRef(null);
-
- // Knowledge graph conversion state
- const [kgModalOpen, setKgModalOpen] = useState(false);
- const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
- const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
- const [kgError, setKgError] = useState(null);
- const [kgConvId, setKgConvId] = useState(null);
-
- // Load models from backend API
- useEffect(() => {
- chatAPI.getModels().then((res: any) => {
- const data = res.data || res;
- const models = Array.isArray(data) ? data : (data?.items || []);
- setChatModels(models);
- if (models.length > 0 && !selectedModelId) {
- setSelectedModelId(models[0].id);
- }
- }).catch(() => {
- setChatModels([]);
- });
- }, []);
-
- // Load conversations from API
- const loadConversations = useCallback(async () => {
- try {
- setLoadingConversations(true);
- const res = await chatAPI.getConversations();
- const convs = unwrapList(res);
- // Sort: pinned first, then by updatedAt desc
- const sorted = [...convs].sort((a: any, b: any) => {
- if (a.pinned && !b.pinned) return -1;
- if (!a.pinned && b.pinned) return 1;
- return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
- });
- // Normalize field names
- const mapped: Conversation[] = sorted.map((c: any) => ({
- id: c.id,
- userId: c.userId || c.user_id || '',
- title: c.title || '未命名对话',
- modelId: c.modelId || c.model_id || '',
- status: c.status || 'active',
- pinned: c.pinned || false,
- knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
- lastMessage: c.lastMessage || c.last_message || '',
- createdAt: c.createdAt || c.created_at || new Date().toISOString(),
- updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
- }));
- setConversations(mapped);
- } catch (err) {
- console.error('加载对话列表失败', err);
- } finally {
- setLoadingConversations(false);
- }
- }, []);
-
- // Load conversations on mount
- useEffect(() => {
- loadConversations();
- }, [loadConversations]);
-
- // Load messages for selected conversation
- useEffect(() => {
- if (!id) {
- setMessages([]);
- return;
- }
- const loadMessages = async () => {
- try {
- setLoadingMessages(true);
- const res = await chatAPI.getMessages(id);
- const msgs = unwrapList(res);
- const mapped: Message[] = msgs.map((m: any) => ({
- id: m.id,
- conversationId: m.conversationId || m.conversation_id || id,
- role: m.role,
- content: m.content,
- tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
- createdAt: m.createdAt || m.created_at || new Date().toISOString(),
- }));
- setMessages(mapped);
- } catch (err) {
- console.error('加载消息失败', err);
- setMessages([]);
- } finally {
- setLoadingMessages(false);
- }
- };
- loadMessages();
- }, [id]);
-
- // Load knowledge bases, agents, skills for attachment dropdowns
- useEffect(() => {
- knowledgeAPI.getBases().then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- if (data?.bases && Array.isArray(data.bases)) {
- setKnowledgeBases(data.bases);
- } else if (Array.isArray(data)) {
- setKnowledgeBases(data);
- } else if (data?.items) {
- setKnowledgeBases(data.items);
- } else {
- setKnowledgeBases([]);
- }
- }).catch(() => {});
- marketAPI.getApps({ type: 'agent' }).then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- let apps: any[] = [];
- if (Array.isArray(data)) apps = data;
- else if (data?.items) apps = data.items;
- // 过滤已卸载和未安装的agent
- setAgents(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
- }).catch(() => {});
- marketAPI.getApps({ type: 'skill' }).then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- let apps: any[] = [];
- if (Array.isArray(data)) apps = data;
- else if (data?.items) apps = data.items;
- // 过滤已卸载和未安装的skill
- setSkills(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
- }).catch(() => {});
- }, []);
-
- // Determine selected conversation
- const selectedConversation = useMemo(() => {
- if (id) {
- return conversations.find(c => c.id === id) || null;
- }
- return conversations[0] || null;
- }, [id, conversations]);
-
- // Filter conversations by search
- const filteredConversations = useMemo(() => {
- const pinned = conversations.filter(c => c.pinned);
- const unpinned = conversations.filter(c => !c.pinned);
- if (!searchQuery.trim()) return [...pinned, ...unpinned];
- const q = searchQuery.toLowerCase();
- return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
- }, [conversations, searchQuery]);
-
- // Selected model info
- const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' };
-
- // Scroll to bottom when messages change
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [messages]);
-
- // Select first conversation if no id param
- useEffect(() => {
- if (!id && conversations.length > 0) {
- navigate(`/chat/${conversations[0].id}`, { replace: true });
- }
- }, [id, conversations, navigate]);
-
- // Handle new conversation
- const handleNewConversation = async () => {
- try {
- const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId });
- const newConv = unwrapData(res);
- const conv: Conversation = {
- id: newConv.id,
- userId: newConv.userId || newConv.user_id || '',
- title: newConv.title || '新对话',
- modelId: newConv.modelId || newConv.model_id || selectedModelId,
- status: 'active',
- pinned: false,
- knowledgeGraphCount: 0,
- createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
- updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
- };
- setConversations(prev => [conv, ...prev]);
- navigate(`/chat/${conv.id}`);
- } catch (err: any) {
- alert('创建对话失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle rename
- const handleRename = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const newTitle = prompt('请输入新名称:');
- if (newTitle && newTitle.trim()) {
- try {
- await chatAPI.updateConversation(convId, { title: newTitle.trim() });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
- } catch (err: any) {
- alert('重命名失败: ' + (err.message || '未知错误'));
- }
- }
- };
-
- // Handle archive
- const handleArchive = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newStatus = conv.status === 'archived' ? 'active' : 'archived';
- try {
- await chatAPI.updateConversation(convId, { status: newStatus });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle delete
- const handleDelete = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- if (!confirm('确定要删除这个对话吗?')) return;
- try {
- await chatAPI.deleteConversation(convId);
- const next = conversations.filter(c => c.id !== convId);
- setConversations(next);
- if (selectedConversation?.id === convId) {
- if (next.length > 0) {
- navigate(`/chat/${next[0].id}`);
- } else {
- navigate('/chat');
- }
- }
- } catch (err: any) {
- alert('删除失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle pin / unpin
- const handlePin = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newPinned = !conv.pinned;
- try {
- await chatAPI.updateConversation(convId, { pinned: newPinned });
- setConversations(prev => {
- const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
- const pinned = updated.filter(c => c.pinned);
- const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
- return [...pinned, ...unpinned];
- });
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle convert to knowledge graph
- const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- setKgModalOpen(true);
- setKgStep(1);
- setKgResult(null);
- setKgError(null);
- setKgConvId(convId);
-
- try {
- await new Promise(r => setTimeout(r, 600));
- setKgStep(2);
-
- const res = await chatAPI.convertToKnowledgeGraph(convId);
- await new Promise(r => setTimeout(r, 400));
- setKgStep(3);
-
- if (res.code === 0) {
- const graph = res.data.graph;
- setKgResult({
- nodes: graph.stats.total_nodes,
- edges: graph.stats.total_edges,
- kbName: res.data.knowledge_base_name,
- kbId: res.data.knowledge_base_id,
- });
- setConversations(prev =>
- prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: (c.knowledgeGraphCount || 0) + 1 } : c)
- );
- } else {
- setKgError(res.message || '转换失败');
- }
- } catch (err) {
- setKgError((err as Error).message || '转换失败,请稍后重试');
- }
- };
-
- // Handle send message - CALLS REAL API
- const handleSend = async () => {
- if (!inputValue.trim() || sending) return;
- const content = inputValue.trim();
- setInputValue('');
- setSending(true);
-
- // Add user message optimistically
- const tempUserMsg: Message = {
- id: `temp_${Date.now()}`,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content,
- tokenUsed: 0,
- createdAt: new Date().toISOString(),
- };
- setMessages(prev => [...prev, tempUserMsg]);
-
- try {
- const res = await chatAPI.sendMessage(selectedConversation!.id, {
- content,
- model_id: selectedModelId || undefined,
- agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
- skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
- knowledge_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
- use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
- });
-
- const data = unwrapData(res);
-
- // Replace temporary user message and add assistant response
- setMessages(prev => {
- const filtered = prev.filter(m => m.id !== tempUserMsg.id);
- const newMsgs: Message[] = [];
- if (data.user_message) {
- newMsgs.push({
- id: data.user_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content: data.user_message.content,
- tokenUsed: data.user_message.token_used || 0,
- createdAt: data.user_message.created_at || new Date().toISOString(),
- });
- }
- if (data.assistant_message) {
- newMsgs.push({
- id: data.assistant_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'assistant',
- content: data.assistant_message.content,
- tokenUsed: data.assistant_message.token_used || 0,
- createdAt: data.assistant_message.created_at || new Date().toISOString(),
- used_skills: data.assistant_message.used_skills || [],
- });
- }
- return [...filtered, ...newMsgs];
- });
-
- // Refresh conversations to update lastMessage
- loadConversations();
-
- // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
- if (selectedConversation?.id && data.assistant_message) {
- const recentMsgs = [
- ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
- { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
- ];
- evolutionAPI.submitTrajectory({
- conversation_id: selectedConversation.id,
- messages: recentMsgs,
- }).catch(() => {});
- }
- } catch (err: any) {
- // Remove optimistic message on error
- setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
- alert('发送失败: ' + (err.message || err.detail || '未知错误'));
- } finally {
- setSending(false);
- inputRef.current?.focus();
- }
- };
-
- // Handle key down
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- return (
-
- {/* Left Sidebar - Conversation List */}
-
-
- {/* Right Main Chat Area */}
-
- {selectedConversation ? (
- <>
- {/* Chat Header */}
-
-
-
-
- {selectedModel.name}
- {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
-
-
- }
- >
- {chatModels.filter(m => m.is_enabled !== false).map(model => (
-
- ))}
-
-
-
{selectedConversation.title}
-
-
-
- {/* Messages Area */}
-
- {loadingMessages ? (
-
- ) : messages.length === 0 ? (
-
-
-
开始一段新对话
-
- 向 {selectedModel.name} 发送消息,开始对话
-
-
- ) : (
- messages.map(msg => {
- // Real used_skills from API response
- const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
- return (
-
-
- {msg.role === 'assistant' ? (
-
- ) : (
- {msg.content}
- )}
-
- {/* Skill usage tags */}
- {msg.role === 'assistant' && usedSkills.length > 0 && (
-
- {usedSkills.slice(0, 3).map(skillName => (
-
window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`}
- title={`使用了专属技能:${skillName}`}
- >
-
- {skillName}
-
- ))}
- {usedSkills.length > 3 && (
-
+{usedSkills.length - 3}
- )}
-
- )}
-
- {formatTime(msg.createdAt)}
- {msg.tokenUsed} tokens
-
-
- );
- })
- )}
- {sending && (
-
- )}
-
-
-
- {/* Input Area */}
-
-
-
-
-
- {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}
-
- }
- >
- 选择知识库(多选)
- {knowledgeBases.map((kb: any) => (
-
- ))}
- {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
-
- )}
- {selectedKbIds.length > 0 && (
-
- )}
- {knowledgeBases.length === 0 && (
- 暂无可用知识库
- )}
-
-
-
-
- {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}
-
- }
- >
- 选择 Agent(多选)
- {agents.map((agent: any) => (
-
- ))}
- {agents.length > 0 && selectedAgentIds.length > 0 && (
-
- )}
- {selectedAgentIds.length > 0 && (
-
- )}
- {agents.length === 0 && (
- 暂无可用 Agent
- )}
-
-
-
-
- {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}
-
- }
- >
- 选择技能(多选)
- {skills.map((skill: any) => (
-
- ))}
- {skills.length > 0 && selectedSkillIds.length > 0 && (
-
- )}
- {selectedSkillIds.length > 0 && (
-
- )}
- {skills.length === 0 && (
- 暂无可用技能
- )}
-
-
-
-
-
-
-
- >
- ) : (
-
-
-
选择或创建对话
-
从左侧列表选择一个对话,或点击上方按钮创建新对话
-
- )}
-
-
- {/* Knowledge Graph Conversion Progress Modal */}
- {kgModalOpen && (
- !kgError && kgStep !== 2 && setKgModalOpen(false)}>
-
e.stopPropagation()}>
-
-
转换知识图谱
-
-
-
- {kgError ? (
-
-
-
-
-
{kgError}
-
-
- ) : kgStep === 3 && kgResult ? (
-
-
-
知识图谱生成完成
-
-
- {kgResult.nodes}
- 节点
-
-
-
- {kgResult.edges}
- 条边
-
-
-
已存入知识库:{kgResult.kbName}
-
-
-
-
-
- ) : (
-
-
-
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
-
- {kgStep > 1 ? (
-
- ) : kgStep === 1 ? (
-
- ) : (
-
1
- )}
-
-
提取对话内容
-
-
-
1 ? 'done' : ''}`}>
- {kgStep === 2 &&
}
-
-
-
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
-
- {kgStep > 2 ? (
-
- ) : kgStep === 2 ? (
-
- ) : (
-
2
- )}
-
-
构建知识图谱
-
-
-
2 ? 'done' : ''}`} />
-
-
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
-
- 3
-
-
完成
-
-
-
- {kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
-
-
- )}
-
-
-
- )}
-
- );
-};
-
-export default ChatPage;
+import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
+import type { Conversation, Message } from '@/types';
+import './ChatPage.css';
+
+// ===== Enhanced Markdown Renderer =====
+const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
+ const html = useMemo(() => {
+ let text = content;
+
+ // 1. Extract code blocks first (protect from other replacements)
+ const codeBlocks: string[] = [];
+ text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
+ const idx = codeBlocks.length;
+ codeBlocks.push('' + escapeHtml(code.trim()) + '
');
+ return '\x00CODEBLOCK' + idx + '\x00';
+ });
+
+ // 2. Extract inline code
+ const inlineCodes: string[] = [];
+ text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
+ const idx = inlineCodes.length;
+ inlineCodes.push('' + escapeHtml(code) + '');
+ return '\x00INLINE' + idx + '\x00';
+ });
+
+ // 2.5 Extract media blocks (
/
');
+ text = text.replace(/\n/g, '
');
+
+ // 10. Restore code blocks and inline codes
+ text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
+ text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
+ text = text.replace(/\x00MEDIA(\d+)\x00/g, (_m: string, idx: string) => mediaBlocks[parseInt(idx)] || '');
+
+ // 11. Wrap in paragraph and clean up
+ text = '
' + text + '
';
+ text = text.replace(/
<\/p>/g, '');
+ text = text.replace(/
()<\/p>/g, '$1');
+ text = text.replace(/(
)<\/p>/g, '$1');
+ text = text.replace(/(
)<\/p>/g, '$1');
+ text = text.replace(/(
/g, '$1');
+ text = text.replace(/(
)<\/p>/g, '$1');
+
+ return text;
+ }, [content]);
+
+ return ;
+};
+
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+// ===== Time Formatting =====
+function formatTime(iso: string): string {
+ const date = new Date(iso);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const oneDay = 24 * 60 * 60 * 1000;
+
+ if (diff < oneDay) {
+ return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+ } else if (diff < 7 * oneDay) {
+ const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
+ return days[date.getDay()];
+ } else if (date.getFullYear() === now.getFullYear()) {
+ return `${date.getMonth() + 1}/${date.getDate()}`;
+ } else {
+ return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
+ }
+}
+
+// ===== Helpers: normalize API responses =====
+function unwrapList(res: any): any[] {
+ const data = res?.data !== undefined ? res.data : res;
+ if (Array.isArray(data)) return data;
+ if (data?.items) return data.items;
+ return [];
+}
+
+function unwrapData(res: any): any {
+ return res?.data !== undefined ? res.data : res;
+}
+
+// ===== Icons =====
+const IconSearch = () => (
+
+);
+
+const IconPlus = () => (
+
+);
+
+const IconMore = () => (
+
+);
+
+const IconSend = () => (
+
+);
+
+const IconChevronDown = () => (
+
+);
+
+const IconEdit = () => (
+
+);
+
+const IconArchive = () => (
+
+);
+
+const IconTrash = () => (
+
+);
+
+const IconPin = () => (
+
+);
+
+const IconGraph = () => (
+
+);
+
+const IconKnowledge = () => (
+
+);
+
+const IconAgent = () => (
+
+);
+
+const IconSkill = () => (
+
+);
+
+const IconModel = () => (
+
+);
+
+const IconEmpty = () => (
+
+);
+
+const IconSpinner = () => (
+
+);
+
+// ===== Dropdown Component =====
+interface DropdownProps {
+ trigger: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+ align?: 'left' | 'right';
+ dropup?: boolean;
+ closeOnClick?: boolean;
+}
+
+const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const menuRef = useRef(null);
+ const openRef = useRef(open);
+ openRef.current = open;
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as Node;
+ // 如果点击在wrapper内部,不关闭
+ if (ref.current?.contains(target)) return;
+ setOpen(false);
+ };
+ document.addEventListener('mousedown', handleClickOutside, true);
+ return () => document.removeEventListener('mousedown', handleClickOutside, true);
+ }, []);
+
+ // 打开时处理z-index
+ useEffect(() => {
+ if (open && dropup) {
+ const menu = menuRef.current;
+ if (menu) {
+ menu.style.zIndex = '9999';
+ }
+ }
+ }, [open, dropup]);
+
+ const alignClass = align === 'right' ? 'align-right' : 'align-left';
+ const dropupClass = dropup ? 'dropup' : '';
+
+ const handleTriggerMouseDown = (e: React.MouseEvent) => {
+ // 阻止冒泡到document,防止被handleClickOutside误关
+ e.stopPropagation();
+ // 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
+ setOpen(prev => !prev);
+ };
+
+ const handleTriggerClick = (e: React.MouseEvent) => {
+ // 阻止click冒泡到父级的导航onClick
+ e.stopPropagation();
+ e.preventDefault();
+ };
+
+ return (
+
+
+ {trigger}
+
+ {open && (
+
e.stopPropagation()}
+ onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
+ >
+ {children}
+
+ )}
+
+ );
+};
+
+// ===== Main Component =====
+const ChatPage: React.FC = () => {
+ const { id } = useParams<{ id?: string }>();
+ const navigate = useNavigate();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedModelId, setSelectedModelId] = useState('');
+ const [chatModels, setChatModels] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [sending, setSending] = useState(false);
+ const [conversations, setConversations] = useState([]);
+ const [messages, setMessages] = useState([]);
+ const [loadingConversations, setLoadingConversations] = useState(true);
+ const [loadingMessages, setLoadingMessages] = useState(false);
+ const [knowledgeBases, setKnowledgeBases] = useState([]);
+ const [agents, setAgents] = useState([]);
+ const [skills, setSkills] = useState([]);
+ // 多选组合模式
+ const [selectedAgentIds, setSelectedAgentIds] = useState([]);
+ const [selectedSkillIds, setSelectedSkillIds] = useState([]);
+ const [selectedKbIds, setSelectedKbIds] = useState([]);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Knowledge graph conversion state
+ const [kgModalOpen, setKgModalOpen] = useState(false);
+ const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
+ const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
+ const [kgError, setKgError] = useState(null);
+ const [kgConvId, setKgConvId] = useState(null);
+
+ // Load models from backend API
+ useEffect(() => {
+ chatAPI.getModels().then((res: any) => {
+ const data = res.data || res;
+ const models = Array.isArray(data) ? data : (data?.items || []);
+ setChatModels(models);
+ if (models.length > 0 && !selectedModelId) {
+ setSelectedModelId(models[0].id);
+ }
+ }).catch(() => {
+ setChatModels([]);
+ });
+ }, []);
+
+ // Load conversations from API
+ const loadConversations = useCallback(async () => {
+ try {
+ setLoadingConversations(true);
+ const res = await chatAPI.getConversations();
+ const convs = unwrapList(res);
+ // Sort: pinned first, then by updatedAt desc
+ const sorted = [...convs].sort((a: any, b: any) => {
+ if (a.pinned && !b.pinned) return -1;
+ if (!a.pinned && b.pinned) return 1;
+ return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
+ });
+ // Normalize field names
+ const mapped: Conversation[] = sorted.map((c: any) => ({
+ id: c.id,
+ userId: c.userId || c.user_id || '',
+ title: c.title || '未命名对话',
+ modelId: c.modelId || c.model_id || '',
+ status: c.status || 'active',
+ pinned: c.pinned || false,
+ knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
+ lastMessage: c.lastMessage || c.last_message || '',
+ createdAt: c.createdAt || c.created_at || new Date().toISOString(),
+ updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
+ }));
+ setConversations(mapped);
+ } catch (err) {
+ console.error('加载对话列表失败', err);
+ } finally {
+ setLoadingConversations(false);
+ }
+ }, []);
+
+ // Load conversations on mount
+ useEffect(() => {
+ loadConversations();
+ }, [loadConversations]);
+
+ // Load messages for selected conversation
+ useEffect(() => {
+ if (!id) {
+ setMessages([]);
+ return;
+ }
+ const loadMessages = async () => {
+ try {
+ setLoadingMessages(true);
+ const res = await chatAPI.getMessages(id);
+ const msgs = unwrapList(res);
+ const mapped: Message[] = msgs.map((m: any) => ({
+ id: m.id,
+ conversationId: m.conversationId || m.conversation_id || id,
+ role: m.role,
+ content: m.content,
+ tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
+ createdAt: m.createdAt || m.created_at || new Date().toISOString(),
+ }));
+ setMessages(mapped);
+ } catch (err) {
+ console.error('加载消息失败', err);
+ setMessages([]);
+ } finally {
+ setLoadingMessages(false);
+ }
+ };
+ loadMessages();
+ }, [id]);
+
+ // Load knowledge bases, agents, skills for attachment dropdowns
+ useEffect(() => {
+ knowledgeAPI.getBases().then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ if (data?.bases && Array.isArray(data.bases)) {
+ setKnowledgeBases(data.bases);
+ } else if (Array.isArray(data)) {
+ setKnowledgeBases(data);
+ } else if (data?.items) {
+ setKnowledgeBases(data.items);
+ } else {
+ setKnowledgeBases([]);
+ }
+ }).catch(() => {});
+ marketAPI.getApps({ type: 'agent' }).then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ let apps: any[] = [];
+ if (Array.isArray(data)) apps = data;
+ else if (data?.items) apps = data.items;
+ // 显示所有已发布的Agent(不需要安装即可在聊天中使用)
+ setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
+ }).catch(() => {});
+ marketAPI.getApps({ type: 'skill' }).then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ let apps: any[] = [];
+ if (Array.isArray(data)) apps = data;
+ else if (data?.items) apps = data.items;
+ // 显示所有已发布的技能(不需要安装即可在聊天中使用)
+ setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
+ }).catch(() => {});
+ }, []);
+
+ // Determine selected conversation
+ const selectedConversation = useMemo(() => {
+ if (id) {
+ return conversations.find(c => c.id === id) || null;
+ }
+ return conversations[0] || null;
+ }, [id, conversations]);
+
+ // Filter conversations by search
+ const filteredConversations = useMemo(() => {
+ const pinned = conversations.filter(c => c.pinned);
+ const unpinned = conversations.filter(c => !c.pinned);
+ if (!searchQuery.trim()) return [...pinned, ...unpinned];
+ const q = searchQuery.toLowerCase();
+ return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
+ }, [conversations, searchQuery]);
+
+ // Selected model info
+ const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' };
+
+ // Scroll to bottom when messages change
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ // Select first conversation if no id param
+ useEffect(() => {
+ if (!id && conversations.length > 0) {
+ navigate(`/chat/${conversations[0].id}`, { replace: true });
+ }
+ }, [id, conversations, navigate]);
+
+ // Handle new conversation
+ const handleNewConversation = async () => {
+ try {
+ const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId });
+ const newConv = unwrapData(res);
+ const conv: Conversation = {
+ id: newConv.id,
+ userId: newConv.userId || newConv.user_id || '',
+ title: newConv.title || '新对话',
+ modelId: newConv.modelId || newConv.model_id || selectedModelId,
+ status: 'active',
+ pinned: false,
+ knowledgeGraphCount: 0,
+ createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
+ updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
+ };
+ setConversations(prev => [conv, ...prev]);
+ navigate(`/chat/${conv.id}`);
+ } catch (err: any) {
+ alert('创建对话失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle rename
+ const handleRename = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const newTitle = prompt('请输入新名称:');
+ if (newTitle && newTitle.trim()) {
+ try {
+ await chatAPI.updateConversation(convId, { title: newTitle.trim() });
+ setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
+ } catch (err: any) {
+ alert('重命名失败: ' + (err.message || '未知错误'));
+ }
+ }
+ };
+
+ // Handle archive
+ const handleArchive = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const conv = conversations.find(c => c.id === convId);
+ if (!conv) return;
+ const newStatus = conv.status === 'archived' ? 'active' : 'archived';
+ try {
+ await chatAPI.updateConversation(convId, { status: newStatus });
+ setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
+ } catch (err: any) {
+ alert('操作失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle delete
+ const handleDelete = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!confirm('确定要删除这个对话吗?')) return;
+ try {
+ await chatAPI.deleteConversation(convId);
+ const next = conversations.filter(c => c.id !== convId);
+ setConversations(next);
+ if (selectedConversation?.id === convId) {
+ if (next.length > 0) {
+ navigate(`/chat/${next[0].id}`);
+ } else {
+ navigate('/chat');
+ }
+ }
+ } catch (err: any) {
+ alert('删除失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle pin / unpin
+ const handlePin = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const conv = conversations.find(c => c.id === convId);
+ if (!conv) return;
+ const newPinned = !conv.pinned;
+ try {
+ await chatAPI.updateConversation(convId, { pinned: newPinned });
+ setConversations(prev => {
+ const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
+ const pinned = updated.filter(c => c.pinned);
+ const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
+ return [...pinned, ...unpinned];
+ });
+ } catch (err: any) {
+ alert('操作失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle convert to knowledge graph
+ const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setKgModalOpen(true);
+ setKgStep(1);
+ setKgResult(null);
+ setKgError(null);
+ setKgConvId(convId);
+
+ try {
+ await new Promise(r => setTimeout(r, 600));
+ setKgStep(2);
+
+ const res = await chatAPI.convertToKnowledgeGraph(convId);
+ await new Promise(r => setTimeout(r, 400));
+ setKgStep(3);
+
+ if (res.code === 0) {
+ const graph = res.data.graph;
+ setKgResult({
+ nodes: graph.stats.total_nodes,
+ edges: graph.stats.total_edges,
+ kbName: res.data.knowledge_base_name,
+ kbId: res.data.knowledge_base_id,
+ });
+ // 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
+ const newCount = res.data.knowledge_graph_count ?? 1;
+ setConversations(prev =>
+ prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
+ );
+ } else {
+ setKgError(res.message || '转换失败');
+ }
+ } catch (err) {
+ setKgError((err as Error).message || '转换失败,请稍后重试');
+ }
+ };
+
+ // Handle send message - CALLS REAL API
+ const handleSend = async () => {
+ if (!inputValue.trim() || sending) return;
+ const content = inputValue.trim();
+ setInputValue('');
+ setSending(true);
+
+ // Add user message optimistically
+ const tempUserMsg: Message = {
+ id: `temp_${Date.now()}`,
+ conversationId: selectedConversation?.id || '',
+ role: 'user',
+ content,
+ tokenUsed: 0,
+ createdAt: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, tempUserMsg]);
+
+ try {
+ const res = await chatAPI.sendMessage(selectedConversation!.id, {
+ content,
+ model_id: selectedModelId || undefined,
+ agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
+ skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
+ knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
+ use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
+ });
+
+ const data = unwrapData(res);
+
+ // Replace temporary user message and add assistant response
+ setMessages(prev => {
+ const filtered = prev.filter(m => m.id !== tempUserMsg.id);
+ const newMsgs: Message[] = [];
+ if (data.user_message) {
+ newMsgs.push({
+ id: data.user_message.id,
+ conversationId: selectedConversation?.id || '',
+ role: 'user',
+ content: data.user_message.content,
+ tokenUsed: data.user_message.token_used || 0,
+ createdAt: data.user_message.created_at || new Date().toISOString(),
+ });
+ }
+ if (data.assistant_message) {
+ newMsgs.push({
+ id: data.assistant_message.id,
+ conversationId: selectedConversation?.id || '',
+ role: 'assistant',
+ content: data.assistant_message.content,
+ tokenUsed: data.assistant_message.token_used || 0,
+ createdAt: data.assistant_message.created_at || new Date().toISOString(),
+ used_skills: data.assistant_message.used_skills || [],
+ });
+ }
+ return [...filtered, ...newMsgs];
+ });
+
+ // Refresh conversations to update lastMessage
+ loadConversations();
+
+ // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
+ if (selectedConversation?.id && data.assistant_message) {
+ const recentMsgs = [
+ ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
+ { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
+ ];
+ evolutionAPI.submitTrajectory({
+ conversation_id: selectedConversation.id,
+ messages: recentMsgs,
+ }).catch(() => {});
+ }
+ } catch (err: any) {
+ // Remove optimistic message on error
+ setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
+ alert('发送失败: ' + (err.message || err.detail || '未知错误'));
+ } finally {
+ setSending(false);
+ inputRef.current?.focus();
+ }
+ };
+
+ // Handle key down
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+ {/* Left Sidebar - Conversation List */}
+
+
+ {/* Right Main Chat Area */}
+
+ {selectedConversation ? (
+ <>
+ {/* Chat Header */}
+
+
+
+
+ {selectedModel.name}
+ {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
+
+
+ }
+ >
+ {chatModels.filter(m => m.is_enabled !== false).map(model => (
+
+ ))}
+
+
+
{selectedConversation.title}
+
+
+
+ {/* Messages Area */}
+
+ {loadingMessages ? (
+
+ ) : messages.length === 0 ? (
+
+
+
开始一段新对话
+
+ 向 {selectedModel.name} 发送消息,开始对话
+
+
+ ) : (
+ messages.map(msg => {
+ // Real used_skills from API response
+ const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
+ return (
+
+
+ {msg.role === 'assistant' ? (
+
+ ) : (
+ {msg.content}
+ )}
+
+ {/* Skill usage tags */}
+ {msg.role === 'assistant' && usedSkills.length > 0 && (
+
+ {usedSkills.slice(0, 3).map(skillName => (
+
{ window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
+ title={`使用了专属技能:${skillName}`}
+ >
+
+ {skillName}
+
+ ))}
+ {usedSkills.length > 3 && (
+
+{usedSkills.length - 3}
+ )}
+
+ )}
+
+ {formatTime(msg.createdAt)}
+ {msg.tokenUsed} tokens
+
+
+ );
+ })
+ )}
+ {sending && (
+
+ )}
+
+
+
+ {/* Input Area */}
+
+
+
+
+
+ {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}
+
+ }
+ >
+ 选择知识库(多选)
+ {knowledgeBases.map((kb: any) => (
+
+ ))}
+ {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
+
+ )}
+ {selectedKbIds.length > 0 && (
+
+ )}
+ {knowledgeBases.length === 0 && (
+ 暂无可用知识库
+ )}
+
+
+
+
+ {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}
+
+ }
+ >
+ 选择 Agent(多选)
+ {agents.map((agent: any) => (
+
+ ))}
+ {agents.length > 0 && selectedAgentIds.length > 0 && (
+
+ )}
+ {selectedAgentIds.length > 0 && (
+
+ )}
+ {agents.length === 0 && (
+ 暂无可用 Agent
+ )}
+
+
+
+
+ {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}
+
+ }
+ >
+ 选择技能(多选)
+ {skills.map((skill: any) => (
+
+ ))}
+ {skills.length > 0 && selectedSkillIds.length > 0 && (
+
+ )}
+ {selectedSkillIds.length > 0 && (
+
+ )}
+ {skills.length === 0 && (
+ 暂无可用技能
+ )}
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
选择或创建对话
+
从左侧列表选择一个对话,或点击上方按钮创建新对话
+
+ )}
+
+
+ {/* Knowledge Graph Conversion Progress Modal */}
+ {kgModalOpen && (
+ !kgError && kgStep !== 2 && setKgModalOpen(false)}>
+
e.stopPropagation()}>
+
+
转换知识图谱
+
+
+
+ {kgError ? (
+
+
+
+
+
{kgError}
+
+
+ ) : kgStep === 3 && kgResult ? (
+
+
+
知识图谱生成完成
+
+
+ {kgResult.nodes}
+ 节点
+
+
+
+ {kgResult.edges}
+ 条边
+
+
+
已存入知识库:{kgResult.kbName}
+
+
+
+
+
+ ) : (
+
+
+
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
+
+ {kgStep > 1 ? (
+
+ ) : kgStep === 1 ? (
+
+ ) : (
+
1
+ )}
+
+
提取对话内容
+
+
+
1 ? 'done' : ''}`}>
+ {kgStep === 2 &&
}
+
+
+
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
+
+ {kgStep > 2 ? (
+
+ ) : kgStep === 2 ? (
+
+ ) : (
+
2
+ )}
+
+
构建知识图谱
+
+
+
2 ? 'done' : ''}`} />
+
+
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
+
+ 3
+
+
完成
+
+
+
+ {kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
+
+
+ )}
+
+
+
+ )}
+
+ );
+};
+
+export default ChatPage;
diff --git a/frontend/src/pages/front/LandingPage.css b/frontend/src/pages/front/LandingPage.css
index 4a6eb09..d33f9b2 100644
--- a/frontend/src/pages/front/LandingPage.css
+++ b/frontend/src/pages/front/LandingPage.css
@@ -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; }
}
diff --git a/frontend/src/pages/front/LandingPage.tsx b/frontend/src/pages/front/LandingPage.tsx
index 0c75749..a45fc6f 100644
--- a/frontend/src/pages/front/LandingPage.tsx
+++ b/frontend/src/pages/front/LandingPage.tsx
@@ -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 }) => (
-
+/* ---------- 内联 Lucide 风格图标(满足"用 Lucide 图标、禁 emoji"铁律) ---------- */
+const Icon: React.FC<{ d: React.ReactNode; size?: number }> = ({ d, size = 20 }) => (
+
);
+/* ---------- 品牌同系渐变(仅靛蓝→青蓝,无第三色相) ---------- */
+const appGradients: Record = {
+ 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 = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
-const typeColors: Record = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
+const typeBadge: Record = {
+ skill: 'badge-skill', agent: 'badge-agent', mcp: 'badge-mcp',
+};
+
+/* ---------- 文案数据(来自用户原始需求提炼) ---------- */
+const painPoints = [
+ { d: <>>,
+ title: '大模型 API 贵且复杂', desc: '多家模型计费规则不一、调用门槛高,个人与小企业难以低成本接入。' },
+ { d: <>>,
+ title: 'Prompt 难调试', desc: '缺乏可视化编排与复用,效果全靠手感,团队无法沉淀最佳实践。' },
+ { d: <>>,
+ title: '数据孤岛', desc: '知识散落在文档、表格、聊天记录里,AI 答非所问,价值无法被激活。' },
+ { d: <>>,
+ title: '个人开发者无变现渠道', desc: '做出好用的 AI 能力却找不到分发与交易场景,难以转化为收入。' },
+];
+
+const valueProps = [
+ { d: <>>,
+ title: '低价订阅 · 无限 Token', kw: '无限Token',
+ desc: '一个低价会员即可放开使用,告别按次计费与额度焦虑,放心把 AI 用进日常。' },
+ { d: <>>,
+ title: '垂直可用办公模板', kw: '办公提效',
+ desc: '写 PPT、出投标书、做运营、排版公众号——开箱即用的模板,把重复劳动交给 AI。' },
+ { d: <>>,
+ title: '可交易的技能市场', kw: 'AI变现',
+ desc: '把自己打磨的技能上架交易,按订阅 / 下载获得收益,能力即资产。' },
+ { d: <>>,
+ title: 'AI 创作试炼场', kw: '',
+ desc: '写小说、做漫剧、谱音乐——低成本试错,让创作想法快速跑通。' },
+ { d: <>>,
+ title: '低成本养龙虾', kw: '',
+ desc: '低成本微调 / 托管你的专属模型,把"养模型"变成可负担的日常。' },
+];
+
+const features = [
+ { d: <>>,
+ title: '多模型 AI 对话', desc: 'DeepSeek / Qwen / 国际模型自由切换,按需选用最优模型。' },
+ { d: <>>,
+ title: '私有知识库', desc: '上传文档构建专属知识库,AI 回答基于你的内容,更精准可靠。' },
+ { d: <>>,
+ title: '技能 / 应用市场', desc: '丰富的 Skills、Agent、MCP 应用,即装即用,一键接入工作流。' },
+ { d: <>>,
+ title: 'Token 驾驶舱', desc: '实时监控 Token 消耗,费用透明可控,每一笔都有据可查。' },
+ { d: <>>,
+ title: 'IM 频道绑定', desc: '微信 / 企微 / 飞书 / QQ 多平台绑定,AI 助手融入日常工作流。' },
+ { d: <>>,
+ 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 = {
- 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 下原生 会被当成路由路径解析,导致菜单失效;
+ // 故改为编程式滚动 + 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 (
-
- {/* Toast notification */}
- {toastMsg && (
-
{toastMsg}
- )}
+
+ {/* JSON-LD 富媒体摘要(SEO / GEO) */}
+
- {/* Hero / Nav bar */}
-
-
-
-