From d404aa4e29bdcb15db3e8cbb4add5033e8eaba32 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Mon, 13 Jul 2026 15:01:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20HashRouter->BrowserRouter=20?= =?UTF-8?q?=E5=B9=B2=E5=87=80URL=E5=8F=AF=E7=8B=AC=E7=AB=8B=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=20+=20=E9=9D=99=E6=80=81=E9=A2=84=E6=B8=B2=E6=9F=93(S?= =?UTF-8?q?SR=E6=B3=A8=E5=85=A5#root/robots/sitemap)=20+=20logo=E5=9B=9E?= =?UTF-8?q?=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/prerender.js | 84 + frontend/src/api/index.ts | 227 +- .../src/components/layout/AdminLayout.tsx | 360 +- frontend/src/main.tsx | 6 +- frontend/src/pages/admin/Dashboard.tsx | 612 ++-- frontend/src/pages/front/ChatPage.tsx | 2215 +++++------ frontend/src/pages/front/LandingPage.css | 882 ++--- frontend/src/pages/front/LandingPage.tsx | 642 ++-- frontend/src/pages/front/MarketPage.css | 3243 +++++++++-------- frontend/src/pages/front/MarketPage.tsx | 3187 ++++++++-------- frontend/src/pages/front/PricingPage.tsx | 2 +- frontend/src/pages/front/ProfilePage.tsx | 6 +- 12 files changed, 5859 insertions(+), 5607 deletions(-) create mode 100644 frontend/prerender.js 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 }) => ( - - {children} - -); - -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 */} -
-
-

最近操作日志

-
-
- - - - - - - - - - - - - {operationLogs.length > 0 ? operationLogs.map(log => ( - - - - - - - - - )) : ( - - )} - -
用户操作目标详情IP时间
{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 }) => ( + + {children} + +); + +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 ( + + {/* Grid lines */} + {yTicks.map((tick, i) => { + const y = padTop + barAreaH - (tick / maxVal) * barAreaH; + return ( + + + {formatK(tick)} + + ); + })} + {/* Bars */} + {data.map((d, i) => { + const barH = (d.tokens / maxVal) * barAreaH; + const x = padLeft + i * (barAreaW / data.length); + const y = padTop + barAreaH - barH; + return ( + + + {`${d.date}: ${d.tokens.toLocaleString()} tokens`} + + {/* X label - every 7th bar */} + {i % Math.max(1, Math.floor(data.length / 8)) === 0 && ( + {formatDate(d.date)} + )} + + ); + })} + + ); +}; + +// 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 ( + + + + + {Math.round(pct * 100)}% + + + ); +}; + +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条 +
+
+ + + + + + + + + + + + + {operationLogs.length > 0 ? operationLogs.map(log => ( + + + + + + + + + )) : ( + + )} + +
用户操作目标详情IP时间
{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 && ( -
    暂无可用技能
    - )} - -
    - -
    -