Files
qihuaiflow/frontend/src/pages/front/EvolutionDashboard.tsx
T
QihuAI Design b3866f770c fix: 全站时间统一为北京时间(Asia/Shanghai)
新增 src/utils/datetime.ts 共享 formatBjTime 工具,
13 个页面(11后台+2前台) 的 toLocaleString/formatDate
改为显式北京时间。Dashboard.tsx 保留图表标签的
formatDate(split) 不受影响。
2026-07-16 12:39:35 +08:00

1086 lines
54 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { evolutionAPI } from '@/api';
import type { EvolutionSkill, EvolutionEvent, EvolutionProfile, SkillTreeData, StatisticsData, SkillState } from '@/types';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import './EvolutionDashboard.css';
import { formatBjTime } from '@/utils/datetime';
// ==================== SVG Icons ====================
const Icon: React.FC<{ children: React.ReactNode; size?: number; className?: string }> = ({ children, size = 16, className }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
{children}
</svg>
);
const panelIcons: Record<string, React.ReactNode> = {
'skill-tree': <Icon size={18}><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></Icon>,
timeline: <Icon size={18}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></Icon>,
statistics: <Icon size={18}><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></Icon>,
profile: <Icon size={18}><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></Icon>,
reports: <Icon size={18}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></Icon>,
};
const panelLabels: Record<string, string> = {
'skill-tree': '技能图谱',
timeline: '进化时间线',
statistics: '技能使用统计',
profile: 'AI能力画像',
reports: '进化报告',
};
// ==================== Config ====================
const stateConfig: Record<SkillState, { label: string; color: string; bg: string; dot: string }> = {
baby: { label: '萌芽', color: '#E8922C', bg: '#FEF4E8', dot: '#E8922C' },
main: { label: '成熟', color: '#219B5E', bg: '#E6F7EE', dot: '#219B5E' },
staging: { label: '验证', color: '#8B5CF6', bg: '#F3F0FF', dot: '#8B5CF6' },
frozen: { label: '冻结', color: '#8B919E', bg: '#F1F3F7', dot: '#BFC4CE' },
};
const eventTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
created: { label: '诞生', color: '#E8922C', icon: <Icon size={12}><path d="M12 2L15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></Icon> },
promoted: { label: '晋升', color: '#219B5E', icon: <Icon size={12}><polyline points="20 6 9 17 4 12"/></Icon> },
rejected: { label: '退回', color: '#D9383A', icon: <Icon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon> },
frozen: { label: '冻结', color: '#8B919E', icon: <Icon size={12}><rect x="3" y="11" width="18" height="11" rx="2"/></Icon> },
unfrozen: { label: '解冻', color: '#3B82F6', icon: <Icon size={12}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/></Icon> },
edited: { label: '更新', color: '#5B5FED', icon: <Icon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/></Icon> },
deleted: { label: '删除', color: '#D9383A', icon: <Icon size={12}><polyline points="3 6 5 6 21 6"/></Icon> },
milestone: { label: '里程碑', color: '#8B5CF6', icon: <Icon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></Icon> },
rolled_back: { label: '回滚', color: '#D9383A', icon: <Icon size={12}><polyline points="1 4 1 10 7 10"/><path d="M3.5 17A9 9 0 1 0 2 12"/></Icon> },
};
const shareThemes = [
{ id: 'deep', name: '深空蓝', bg: 'linear-gradient(135deg, #1A1A3E 0%, #2D2D6B 50%, #1A1A2E 100%)', accent: '#8185F5', text: '#E8E6FF' },
{ id: 'aurora', name: '极光绿', bg: 'linear-gradient(135deg, #0A2E1F 0%, #0F4A33 50%, #0A2E1F 100%)', accent: '#219B5E', text: '#D1FAE5' },
{ id: 'sunset', name: '暖阳橙', bg: 'linear-gradient(135deg, #3D1F00 0%, #5C2D0E 50%, #3D1F00 100%)', accent: '#E8922C', text: '#FEF3C7' },
];
// ==================== Real API version history ====================
// Version history is now fetched from the real API via evolutionAPI.getSkillHistory()
// ==================== Panel A: Skill Tree ====================
const SkillTreePanel: React.FC<{
skills: EvolutionSkill[];
animEnabled: boolean;
onToggleAnim: () => void;
}> = ({ skills, animEnabled, onToggleAnim }) => {
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
const [showVersionHistory, setShowVersionHistory] = useState(false);
const [showRollbackConfirm, setShowRollbackConfirm] = useState<{ version: number } | null>(null);
const [showPublishModal, setShowPublishModal] = useState(false);
const [publishPrice, setPublishPrice] = useState(0);
const [publishDesc, setPublishDesc] = useState('');
const [rollbackMsg, setRollbackMsg] = useState('');
const [publishMsg, setPublishMsg] = useState('');
const [versionHistory, setVersionHistory] = useState<EvolutionEvent[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const categories = [...new Set(skills.map(s => s.category))];
const groupedByCategory = categories.map(cat => ({
category: cat,
skills: skills.filter(s => s.category === cat),
}));
// Fetch real version history from API
const loadVersionHistory = async (skillName: string) => {
setLoadingHistory(true);
try {
const res = await evolutionAPI.getSkillHistory(skillName);
if (res?.data) {
const events = Array.isArray(res.data) ? res.data : (res.data as any)?.events || [];
setVersionHistory(events);
} else {
setVersionHistory([]);
}
} catch {
setVersionHistory([]);
} finally {
setLoadingHistory(false);
}
};
const handleRollback = async (targetVersion: number) => {
if (!selectedSkill) return;
try {
await evolutionAPI.rollbackSkill(selectedSkill.skill_name, targetVersion);
setRollbackMsg(`技能 "${selectedSkill.skill_name}" 已回滚到版本 v${targetVersion}`);
setShowVersionHistory(false);
setShowRollbackConfirm(null);
setSelectedSkill(prev => prev ? { ...prev, version: targetVersion } : null);
setTimeout(() => setRollbackMsg(''), 3000);
} catch (err: any) {
setRollbackMsg(`回滚失败: ${err.message || '未知错误'}`);
setTimeout(() => setRollbackMsg(''), 3000);
}
};
const handlePublish = async () => {
if (!selectedSkill) return;
try {
await evolutionAPI.publishSkill(selectedSkill.skill_name, { price: publishPrice, market_description: publishDesc });
setPublishMsg(`技能 "${selectedSkill.skill_name}" 已发布到应用市场`);
setShowPublishModal(false);
setTimeout(() => setPublishMsg(''), 3000);
} catch (err: any) {
setPublishMsg(`发布失败: ${err.message || '未知错误'}`);
setTimeout(() => setPublishMsg(''), 3000);
}
};
return (
<div className="evo-panel-content">
{/* Rollback toast */}
{rollbackMsg && <div className="evo-toast evo-toast-success">{rollbackMsg}</div>}
{/* Publish toast */}
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
{/* Anim Toggle */}
<div className="evo-panel-toolbar">
<label className="evo-anim-toggle">
<input type="checkbox" checked={animEnabled} onChange={onToggleAnim} />
<span>动画效果</span>
</label>
</div>
{/* Skill detail modal */}
{selectedSkill && !showVersionHistory && (
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
<div className="evo-skill-detail-card" onClick={e => e.stopPropagation()}>
<div className="evo-skill-detail-header">
<span className={`evo-state-badge ${selectedSkill.state}`}>{stateConfig[selectedSkill.state].label}</span>
<h3>{selectedSkill.skill_name}</h3>
<button className="evo-close-btn" onClick={() => setSelectedSkill(null)}>
<Icon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon>
</button>
</div>
<p className="evo-skill-detail-desc">{selectedSkill.description}</p>
<div className="evo-skill-detail-meta">
<div className="evo-meta-item">
<span className="evo-meta-label">版本</span>
<span>v{selectedSkill.version}</span>
</div>
<div className="evo-meta-item">
<span className="evo-meta-label">使用次数</span>
<span>{selectedSkill.use_count.toLocaleString()}</span>
</div>
<div className="evo-meta-item">
<span className="evo-meta-label">分类</span>
<span>{selectedSkill.category}</span>
</div>
<div className="evo-meta-item">
<span className="evo-meta-label">创建日期</span>
<span>{new Date(selectedSkill.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="evo-skill-detail-tags">
{selectedSkill.tags.map(t => <span key={t} className="evo-tag">{t}</span>)}
</div>
<div className="evo-skill-detail-actions">
<button className="evo-btn evo-btn-outline evo-btn-sm">编辑技能</button>
<button className="evo-btn evo-btn-outline evo-btn-sm" onClick={() => { setShowVersionHistory(true); loadVersionHistory(selectedSkill.skill_name); }}>版本历史</button>
<button className="evo-btn evo-btn-outline evo-btn-sm">来源对话</button>
{selectedSkill.state === 'main' && (
<button className="evo-btn evo-btn-outline evo-btn-sm" style={{ color: '#E8922C', borderColor: '#E8922C' }} onClick={() => { setPublishDesc(selectedSkill.description); setPublishPrice(0); setShowPublishModal(true); }}>
发布到市场
</button>
)}
</div>
</div>
</div>
)}
{/* Version History Modal */}
{selectedSkill && showVersionHistory && (
<div className="evo-skill-detail-modal" onClick={() => { setShowVersionHistory(false); setShowRollbackConfirm(null); }}>
<div className="evo-skill-detail-card" onClick={e => e.stopPropagation()}>
<div className="evo-skill-detail-header">
<h3>版本历史 - {selectedSkill.skill_name}</h3>
<button className="evo-close-btn" onClick={() => { setShowVersionHistory(false); setShowRollbackConfirm(null); }}>
<Icon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon>
</button>
</div>
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-lg)' }}>
当前版本: v{selectedSkill.version}
</p>
{loadingHistory ? (
<p style={{ textAlign: 'center', padding: 'var(--space-xl)', color: 'var(--color-text-tertiary)' }}>加载中...</p>
) : (
<div className="evo-version-list">
{versionHistory.length > 0 ? versionHistory.map((entry, idx) => {
const ec = eventTypeConfig[entry.event_type] || eventTypeConfig.edited;
const entryVersion = entry.version_after || entry.version_before || 1;
const isCurrent = entryVersion === selectedSkill.version;
return (
<div key={entry.id || idx} className="evo-version-item">
<div className="evo-version-line">
<div className="evo-version-dot" style={{ background: isCurrent ? 'var(--color-accent)' : ec.color }}>
{ec.icon}
</div>
{idx < versionHistory.length - 1 && <div className="evo-version-connector" />}
</div>
<div className="evo-version-content">
<div className="evo-version-header">
<span className="evo-version-num">v{entryVersion}</span>
{isCurrent && <span className="evo-version-current">当前</span>}
<span className="evo-version-type" style={{ color: ec.color }}>{ec.label}</span>
</div>
<p className="evo-version-desc">{entry.description}</p>
<div className="evo-version-meta">
<span>{new Date(entry.created_at).toLocaleDateString()}</span>
{!isCurrent && (
<button
className="evo-btn evo-btn-outline evo-btn-sm"
style={{ color: '#D9383A', borderColor: '#D9383A' }}
onClick={() => setShowRollbackConfirm({ version: entryVersion })}
>
回滚到此版本
</button>
)}
</div>
</div>
</div>
);
}) : (
<p style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-xl)' }}>暂无版本历史</p>
)}
</div>
)}
</div>
</div>
)}
{/* Rollback Confirm Modal */}
{showRollbackConfirm && selectedSkill && (
<div className="evo-confirm-overlay" onClick={() => setShowRollbackConfirm(null)}>
<div className="evo-confirm-card" onClick={e => e.stopPropagation()}>
<div className="evo-confirm-icon">
<Icon size={28}><polyline points="1 4 1 10 7 10"/><path d="M3.5 17A9 9 0 1 0 2 12"/></Icon>
</div>
<h4>确认回滚?</h4>
<p>确认回滚到版本 <strong>v{showRollbackConfirm.version}</strong>?当前版本 <strong>v{selectedSkill.version}</strong> 的修改将被撤销。</p>
<div className="evo-confirm-actions">
<button className="evo-btn evo-btn-outline" onClick={() => setShowRollbackConfirm(null)}>取消</button>
<button className="evo-btn evo-btn-primary" style={{ background: '#D9383A' }} onClick={() => handleRollback(showRollbackConfirm.version)}>确认回滚</button>
</div>
</div>
</div>
)}
{/* Publish to Market Modal */}
{showPublishModal && selectedSkill && (
<div className="evo-confirm-overlay" onClick={() => setShowPublishModal(false)}>
<div className="evo-confirm-card evo-publish-card" onClick={e => e.stopPropagation()}>
<h4>发布到应用市场</h4>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-xl)' }}>
将进化技能 "{selectedSkill.skill_name}" 发布到应用市场Skills分类
</p>
<div className="admin-form-group">
<label className="admin-form-label">价格 (元)</label>
<input className="admin-form-input" type="number" min={0} value={publishPrice} onChange={e => setPublishPrice(Number(e.target.value))} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">市场描述</label>
<textarea className="admin-form-input" rows={3} value={publishDesc} onChange={e => setPublishDesc(e.target.value)} />
</div>
<div className="evo-confirm-actions">
<button className="evo-btn evo-btn-outline" onClick={() => setShowPublishModal(false)}>取消</button>
<button className="evo-btn evo-btn-primary" onClick={handlePublish}>确认发布</button>
</div>
</div>
</div>
)}
{/* Skill tree */}
<div className="evo-skill-tree">
{groupedByCategory.map(group => (
<div key={group.category} className="evo-skill-branch">
<div className="evo-branch-label">{group.category}</div>
<div className="evo-branch-nodes">
{group.skills.map((skill, idx) => {
const sc = stateConfig[skill.state];
const isHovered = hoveredSkill === skill.id;
return (
<React.Fragment key={skill.id}>
{idx > 0 && <div className="evo-node-connector" />}
<div
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`}
onMouseEnter={() => setHoveredSkill(skill.id)}
onMouseLeave={() => setHoveredSkill(null)}
onClick={() => setSelectedSkill(skill)}
>
<div className="evo-node-dot" style={{ background: sc.dot }} />
<div className="evo-node-info">
<span className="evo-node-name">{skill.skill_name}</span>
<span className="evo-node-meta">{skill.use_count}次</span>
</div>
{isHovered && (
<div className="evo-node-tooltip">
<div className="evo-tooltip-title">{skill.skill_name}</div>
<div className="evo-tooltip-desc">{skill.description}</div>
<div className="evo-tooltip-meta">
<span>v{skill.version}</span>
<span>{new Date(skill.updated_at).toLocaleDateString()}</span>
</div>
</div>
)}
</div>
</React.Fragment>
);
})}
</div>
</div>
))}
</div>
<div className="evo-legend">
{Object.entries(stateConfig).map(([key, sc]) => (
<span key={key} className="evo-legend-item">
<span className="evo-legend-dot" style={{ background: sc.dot }} />
{sc.label}
</span>
))}
</div>
</div>
);
};
// ==================== Panel B: Timeline (with export) ====================
const TimelinePanel: React.FC<{
events: EvolutionEvent[];
onViewSkill?: (skillName: string) => void;
}> = ({ events, onViewSkill }) => {
const [filterType, setFilterType] = useState<string>('all');
const [showExportMenu, setShowExportMenu] = useState(false);
const [dateRange, setDateRange] = useState<'7d' | '30d' | 'all'>('all');
const [exporting, setExporting] = useState(false);
const [exportMsg, setExportMsg] = useState('');
const timelineRef = useRef<HTMLDivElement>(null);
const now = new Date();
let filteredEvents = filterType === 'all' ? events : events.filter(e => e.event_type === filterType);
// Apply date range filter
if (dateRange !== 'all') {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - (dateRange === '7d' ? 7 : 30));
filteredEvents = filteredEvents.filter(e => new Date(e.created_at) >= cutoff);
}
const sortedEvents = [...filteredEvents].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const handleExport = async (format: 'png' | 'pdf') => {
setExporting(true);
setShowExportMenu(false);
const username = (() => {
try { return JSON.parse(localStorage.getItem('aiflow_user') || '{}').username || 'user'; } catch { return 'user'; }
})();
const dateStr = new Date().toISOString().slice(0, 10);
const filename = `AI进化时间线_${username}_${dateStr}`;
try {
const target = timelineRef.current;
if (!target) throw new Error('未找到时间线DOM');
const canvas = await html2canvas(target, {
backgroundColor: '#FFFFFF',
scale: 2,
logging: false,
});
if (format === 'png') {
const link = document.createElement('a');
link.download = `${filename}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
setExportMsg(`时间线已导出为 PNG: ${filename}.png`);
} else {
const pdf = new jsPDF('p', 'mm', 'a4');
const imgData = canvas.toDataURL('image/png');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pageWidth - 20;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 添加标题
pdf.setFontSize(16);
pdf.text('我的AI进化时间线', 10, 15);
pdf.setFontSize(10);
pdf.text(`生成日期: ${dateStr}`, 10, 22);
pdf.text('Qihu-AIFlow', pageWidth - 10, 22, { align: 'right' });
// 单页添加图片,自动缩放适应
const maxHeight = pageHeight - 35;
const finalH = Math.min(imgHeight, maxHeight);
pdf.addImage(imgData, 'PNG', 10, 28, imgWidth, finalH);
// 底部水印
pdf.setFontSize(8);
pdf.setTextColor(200, 200, 200);
pdf.text('Qihu-AIFlow', pageWidth / 2, pageHeight - 5, { align: 'center' });
pdf.save(`${filename}.pdf`);
setExportMsg(`时间线已导出为 PDF: ${filename}.pdf`);
}
} catch (err: any) {
setExportMsg(`导出失败: ${err.message || '未知错误'}`);
} finally {
setExporting(false);
setTimeout(() => setExportMsg(''), 3000);
}
};
return (
<div className="evo-panel-content">
{/* Export toast */}
{exportMsg && <div className="evo-toast evo-toast-success">{exportMsg}</div>}
{/* Toolbar: date range + export */}
<div className="evo-panel-toolbar">
<div className="evo-timeline-filters">
{['all', 'created', 'promoted', 'milestone', 'edited', 'frozen', 'rejected'].map(t => (
<button key={t} className={`evo-filter-chip ${filterType === t ? 'active' : ''}`} onClick={() => setFilterType(t)}>
{t === 'all' ? '全部' : eventTypeConfig[t]?.label || t}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center' }}>
<select className="evo-date-select" value={dateRange} onChange={e => setDateRange(e.target.value as any)}>
<option value="all">全部时间</option>
<option value="30d">最近30天</option>
<option value="7d">最近7天</option>
</select>
<div className="evo-export-wrap">
<button
className="evo-btn evo-btn-outline evo-btn-sm"
onClick={() => setShowExportMenu(!showExportMenu)}
disabled={exporting}
>
{exporting ? '导出中...' : '导出'}
</button>
{showExportMenu && (
<div className="evo-export-dropdown">
<button onClick={() => handleExport('png')}>导出为 PNG</button>
<button onClick={() => handleExport('pdf')}>导出为 PDF</button>
</div>
)}
</div>
</div>
</div>
{/* Timeline */}
<div className="evo-timeline" ref={timelineRef}>
{sortedEvents.map((event, idx) => {
const ec = eventTypeConfig[event.event_type];
return (
<div key={event.id} className="evo-timeline-item">
<div className="evo-timeline-line">
<div className="evo-timeline-dot" style={{ background: ec?.color || '#5B5FED' }}>{ec?.icon}</div>
{idx < sortedEvents.length - 1 && <div className="evo-timeline-connector" />}
</div>
<div className="evo-timeline-card">
<div className="evo-timeline-card-header">
<span className="evo-timeline-type" style={{ color: ec?.color }}>{ec?.label}</span>
<span className="evo-timeline-skill" style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={() => onViewSkill?.(event.skill_name)}>{event.skill_name}</span>
</div>
<p className="evo-timeline-desc">{event.description}</p>
<div className="evo-timeline-time">
<Icon size={12}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></Icon>
{formatBjTime(event.created_at)}
</div>
</div>
</div>
);
})}
{sortedEvents.length === 0 && (
<p style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-3xl)' }}>该时间范围内暂无进化事件</p>
)}
</div>
</div>
);
};
// ==================== Panel C: Statistics ====================
const StatisticsPanel: React.FC<{ stats: StatisticsData; onViewSkill?: (skillName: string) => void }> = ({ stats, onViewSkill }) => {
const sortedByUse = [...stats.ranking].sort((a, b) => b.use_count - a.use_count);
const maxUse = sortedByUse[0]?.use_count || 1;
const totalUse = stats.total_use_count;
const zeroUseSkills = stats.low_use_skills || [];
const catColors = ['#5B5FED', '#E8922C', '#219B5E', '#D9383A', '#8B5CF6'];
return (
<div className="evo-panel-content">
<div className="evo-stats-grid">
<div className="evo-stats-card">
<h4 className="evo-card-title">技能使用排行</h4>
<div className="evo-ranking-list">
{sortedByUse.map((s, idx) => (
<div key={s.skill_name} className="evo-ranking-item" style={{ cursor: 'pointer' }} onClick={() => onViewSkill?.(s.skill_name)}>
<span className={`evo-rank-num ${idx < 3 ? 'top' : ''}`}>{idx + 1}</span>
<div className="evo-rank-info">
<span className="evo-rank-name">{s.skill_name}</span>
<span className="evo-rank-cat">{s.category}</span>
</div>
<div className="evo-rank-bar-wrap">
<div className="evo-rank-bar" style={{ width: `${(s.use_count / maxUse) * 100}%`, background: catColors[idx % 5] }} />
</div>
<span className="evo-rank-count">{s.use_count}</span>
</div>
))}
</div>
</div>
<div className="evo-stats-card">
<h4 className="evo-card-title">技能调用趋势(近7天)</h4>
<div className="evo-trend-chart">
<div className="evo-trend-lines">
{sortedByUse.slice(0, 4).map((s, idx) => {
const sAny = s as any;
const trend: number[] = sAny.trend || [0,0,0,0,0,0,0];
const maxT = Math.max(...trend, 1);
return (
<div key={s.skill_name} className="evo-trend-line-row">
<span className="evo-trend-label" style={{ color: catColors[idx] }}>{s.skill_name}</span>
<div className="evo-trend-bar-row">
{trend.map((v: number, i: number) => (
<div key={i} className="evo-trend-bar" style={{ height: `${(v / maxT) * 100}%`, background: catColors[idx] }} title={`Day ${i + 1}: ${v}次`} />
))}
</div>
<span className="evo-trend-total">{s.use_count}</span>
</div>
);
})}
</div>
<div className="evo-trend-xaxis">
{['一', '二', '三', '四', '五', '六', '日'].map(d => <span key={d}>{d}</span>)}
</div>
</div>
</div>
<div className="evo-stats-card">
<h4 className="evo-card-title">技能类别分布</h4>
<div className="evo-category-pie">
<div className="evo-pie-visual">
<svg width="140" height="140" viewBox="0 0 140 140">
{(() => {
let cumulative = 0;
const dist = stats.category_distribution || [];
const entries = [...dist].sort((a, b) => b.count - a.count);
return entries.map((item, idx) => {
const count = item.count;
const angle = (count / totalUse) * 360;
const start = cumulative;
cumulative += angle;
const startRad = (start - 90) * Math.PI / 180;
const endRad = (start + angle - 90) * Math.PI / 180;
const cx = 70, cy = 70, r = 55;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const large = angle > 180 ? 1 : 0;
return <path key={idx} d={`M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`} fill={catColors[idx % 5]} opacity="0.85" />;
});
})()}
</svg>
<div className="evo-pie-center">
<span className="evo-pie-total">{totalUse.toLocaleString()}</span>
<span className="evo-pie-label">总次数</span>
</div>
</div>
<div className="evo-pie-legend">
{[...(stats.category_distribution || [])].sort((a, b) => b.count - a.count).map((item, idx) => (
<div key={item.category} className="evo-pie-legend-item">
<span className="evo-pie-dot" style={{ background: catColors[idx % 5] }} />
<span className="evo-pie-name">{item.category}</span>
<span className="evo-pie-pct">{Math.round((item.count / totalUse) * 100)}%</span>
</div>
))}
</div>
</div>
</div>
{zeroUseSkills.length > 0 && (
<div className="evo-stats-card evo-stats-full">
<h4 className="evo-card-title">低使用技能提醒<span className="evo-card-subtitle">超过30天未使用的技能</span></h4>
<div className="evo-zero-list">
{zeroUseSkills.map((s: any) => (
<div key={s.skill_name} className="evo-zero-item">
<Icon size={14}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></Icon>
<span>{s.skill_name}</span>
<span className="evo-zero-cat">{s.category}</span>
<span className="evo-zero-last">最近: {new Date(s.last_used_at).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
// ==================== Panel D: Profile (with share card) ====================
const ProfilePanel: React.FC<{ profile: EvolutionProfile; skills: EvolutionSkill[] }> = ({ profile, skills }) => {
const levelProgress = (profile.level / 10) * 100;
const [showShareCard, setShowShareCard] = useState(false);
const [shareTheme, setShareTheme] = useState('deep');
const [shareMsg, setShareMsg] = useState('');
const shareCardRef = useRef<HTMLDivElement>(null);
const currentTheme = shareThemes.find(t => t.id === shareTheme) || shareThemes[0];
const handleShare = async (action: 'save' | 'copy') => {
if (!shareCardRef.current) {
setShowShareCard(false);
return;
}
try {
const canvas = await html2canvas(shareCardRef.current, {
backgroundColor: null,
scale: 2,
logging: false,
});
if (action === 'save') {
const link = document.createElement('a');
link.download = `AI进化画像_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
setShareMsg('画像卡片已保存到本地');
} else {
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'));
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
setShareMsg('画像卡片已复制到剪贴板');
}
} catch (err: any) {
setShareMsg(`操作失败: ${err.message || '未知错误'}`);
}
setShowShareCard(false);
setTimeout(() => setShareMsg(''), 3000);
};
return (
<div className="evo-panel-content">
{/* Share toast */}
{shareMsg && <div className="evo-toast evo-toast-success">{shareMsg}</div>}
{/* Share button */}
<div className="evo-panel-toolbar">
<button className="evo-btn evo-btn-outline evo-btn-sm" onClick={() => setShowShareCard(true)}>
<Icon size={14}><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></Icon>
分享画像
</button>
</div>
{/* Share Card Modal */}
{showShareCard && (
<div className="evo-confirm-overlay" onClick={() => setShowShareCard(false)}>
<div className="evo-share-modal" onClick={e => e.stopPropagation()}>
<div className="evo-share-modal-header">
<h4>AI能力画像分享</h4>
<button className="evo-close-btn" onClick={() => setShowShareCard(false)}>
<Icon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon>
</button>
</div>
{/* Theme picker */}
<div className="evo-share-themes">
{shareThemes.map(t => (
<button
key={t.id}
className={`evo-share-theme-btn ${shareTheme === t.id ? 'active' : ''}`}
style={{ background: t.bg }}
onClick={() => setShareTheme(t.id)}
>
<span style={{ color: t.accent }}>{t.name}</span>
</button>
))}
</div>
{/* Share Card Preview */}
<div className="evo-share-card-wrap">
<div className="evo-share-card" ref={shareCardRef} style={{ background: currentTheme.bg, color: currentTheme.text }}>
<div className="evo-share-card-top">
<div className="evo-share-logo">
<svg width="24" height="24" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill={currentTheme.accent} />
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
<span style={{ fontSize: '13px', fontWeight: 700 }}>AIFlow</span>
</div>
<h3 style={{ fontSize: '18px', fontWeight: 700, margin: '8px 0 0', color: currentTheme.text }}>我的AI进化画像</h3>
</div>
{/* Level */}
<div className="evo-share-level">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="6" />
<circle cx="50" cy="50" r="42" fill="none" stroke={currentTheme.accent} strokeWidth="6"
strokeLinecap="round" strokeDasharray={`${2 * Math.PI * 42}`}
strokeDashoffset={`${2 * Math.PI * 42 * (1 - levelProgress / 100)}`}
transform="rotate(-90 50 50)" />
</svg>
<div className="evo-share-level-center">
<span className="evo-share-level-num" style={{ color: currentTheme.accent }}>{profile.level}</span>
<span className="evo-share-level-label">{profile.level_title}</span>
</div>
</div>
{/* Dimensions */}
<div className="evo-share-dims">
{profile.dimensions.map(d => (
<div key={d.label} className="evo-share-dim-item">
<span className="evo-share-dim-label">{d.label}</span>
<div className="evo-share-dim-bar">
<div className="evo-share-dim-fill" style={{ width: `${(d.value / d.maxValue) * 100}%`, background: currentTheme.accent }} />
</div>
<span className="evo-share-dim-val">{d.value}</span>
</div>
))}
</div>
{/* Tags */}
<div className="evo-share-tags">
{profile.tags.slice(0, 6).map(tag => (
<span key={tag} className="evo-share-tag" style={{ borderColor: currentTheme.accent, color: currentTheme.accent }}>{tag}</span>
))}
</div>
{/* Footer */}
<div className="evo-share-card-footer">
<span style={{ opacity: 0.5, fontSize: '11px' }}>{new Date().toLocaleDateString()}</span>
<span style={{ opacity: 0.5, fontSize: '11px' }}>Qihu-AIFlow</span>
</div>
</div>
</div>
{/* Actions */}
<div className="evo-share-actions">
<button className="evo-btn evo-btn-primary" onClick={() => handleShare('save')}>保存到相册</button>
<button className="evo-btn evo-btn-outline" onClick={() => handleShare('copy')}>复制图片</button>
</div>
</div>
</div>
)}
{/* Profile content */}
<div className="evo-profile-grid">
<div className="evo-profile-card evo-level-card">
<div className="evo-level-display">
<div className="evo-level-circle-wrap">
<svg width="160" height="160" viewBox="0 0 160 160">
<circle cx="80" cy="80" r="68" fill="none" stroke="var(--color-border)" strokeWidth="10" />
<circle cx="80" cy="80" r="68" fill="none" stroke="url(#levelGrad)" strokeWidth="10" strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 68}`} strokeDashoffset={`${2 * Math.PI * 68 * (1 - levelProgress / 100)}`}
transform="rotate(-90 80 80)" style={{ transition: 'stroke-dashoffset 1s ease' }} />
<defs>
<linearGradient id="levelGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#5B5FED" /><stop offset="100%" stopColor="#8185F5" />
</linearGradient>
</defs>
</svg>
<div className="evo-level-center">
<span className="evo-level-num">{profile.level}</span>
<span className="evo-level-title">{profile.level_title}</span>
</div>
</div>
</div>
<div className="evo-level-stats">
<div className="evo-level-stat-item"><span className="evo-ls-num">{profile.total_skills}</span><span className="evo-ls-label">总技能</span></div>
<div className="evo-level-stat-item"><span className="evo-ls-num">{profile.active_skills}</span><span className="evo-ls-label">活跃中</span></div>
<div className="evo-level-stat-item"><span className="evo-ls-num">{profile.staging_skills}</span><span className="evo-ls-label">验证中</span></div>
<div className="evo-level-stat-item"><span className="evo-ls-num">{profile.total_use_count.toLocaleString()}</span><span className="evo-ls-label">总调用</span></div>
</div>
</div>
<div className="evo-profile-card evo-radar-card">
<h4 className="evo-card-title">能力维度</h4>
<div className="evo-radar-container">
<svg width="280" height="240" viewBox="0 0 280 240">
{[0.2, 0.4, 0.6, 0.8, 1.0].map((scale, ri) => (
<polygon key={ri}
points={profile.dimensions.map((_, di) => { const angle = (di * 360 / profile.dimensions.length - 90) * Math.PI / 180; const r = 90 * scale; return `${140 + r * Math.cos(angle)},${120 + r * Math.sin(angle)}`; }).join(' ')}
fill="none" stroke="var(--color-border)" strokeWidth="1" opacity={ri === 4 ? 0.6 : 0.3} />
))}
{profile.dimensions.map((_, di) => { const angle = (di * 360 / profile.dimensions.length - 90) * Math.PI / 180; return <line key={di} x1="140" y1="120" x2={140 + 90 * Math.cos(angle)} y2={120 + 90 * Math.sin(angle)} stroke="var(--color-border)" strokeWidth="1" opacity="0.5" />; })}
<polygon
points={profile.dimensions.map((d, di) => { const angle = (di * 360 / profile.dimensions.length - 90) * Math.PI / 180; const r = 90 * (d.value / d.maxValue); return `${140 + r * Math.cos(angle)},${120 + r * Math.sin(angle)}`; }).join(' ')}
fill="rgba(91, 95, 237, 0.15)" stroke="#5B5FED" strokeWidth="2" />
{profile.dimensions.map((d, di) => { const angle = (di * 360 / profile.dimensions.length - 90) * Math.PI / 180; const r = 90 * (d.value / d.maxValue); return <circle key={di} cx={140 + r * Math.cos(angle)} cy={120 + r * Math.sin(angle)} r="4" fill="#5B5FED" stroke="#FFFFFF" strokeWidth="2" />; })}
{profile.dimensions.map((d, di) => { const angle = (di * 360 / profile.dimensions.length - 90) * Math.PI / 180; const lx = 140 + 108 * Math.cos(angle); const ly = 120 + 108 * Math.sin(angle); return <text key={di} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle" fill="var(--color-text-secondary)" fontSize="11" fontFamily="var(--font-sans)">{d.label}</text>; })}
</svg>
</div>
</div>
<div className="evo-profile-card evo-tags-card">
<h4 className="evo-card-title">能力标签</h4>
<div className="evo-tag-cloud">
{profile.tags.map((tag, i) => {
const sizes = ['evo-tag-sm', 'evo-tag-md', 'evo-tag-lg', 'evo-tag-xl'];
const tagColors = ['#5B5FED', '#E8922C', '#219B5E', '#8B5CF6', '#D9383A', '#3B82F6'];
return <span key={tag} className={`evo-tag ${sizes[Math.min(i, sizes.length - 1)]}`} style={{ color: tagColors[i % 6], borderColor: tagColors[i % 6] }}>{tag}</span>;
})}
</div>
</div>
<div className="evo-profile-card evo-suggestions-card">
<h4 className="evo-card-title">成长建议</h4>
<div className="evo-suggestions-list">
{profile.growth_suggestions.map((s: string, i: number) => (
<div key={i} className="evo-suggestion-item">
<Icon size={16}><path d="M12 2L15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></Icon>
<span>{s}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
// ==================== Panel E: Evolution Reports (v2) ====================
const ReportsPanel: React.FC = () => {
const [reports, setReports] = useState<import('@/types').EvolutionReport[]>([]);
const [selectedReport, setSelectedReport] = useState<import('@/types').EvolutionReport | null>(null);
const [loadingReports, setLoadingReports] = useState(true);
useEffect(() => {
evolutionAPI.getReports().then(res => {
const data = res?.data;
setReports(Array.isArray(data) ? data : (data as any)?.reports || []);
}).catch(() => {}).finally(() => setLoadingReports(false));
}, []);
const handleViewReport = async (reportId: string) => {
try {
const res = await evolutionAPI.getReportDetail(reportId);
if (res?.data) {
setSelectedReport(res.data);
}
} catch {}
};
const reportData = selectedReport?.report_data || {};
return (
<div className="evo-panel-content">
{selectedReport ? (
<div className="evo-skill-detail-modal" onClick={() => setSelectedReport(null)}>
<div className="evo-skill-detail-card" onClick={e => e.stopPropagation()}>
<div className="evo-skill-detail-header">
<h3>{selectedReport.report_type === 'weekly' ? '周报' : '月报'} - {selectedReport.period_start} ~ {selectedReport.period_end}</h3>
<button className="evo-close-btn" onClick={() => setSelectedReport(null)}>
<Icon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon>
</button>
</div>
<div className="evo-report-detail">
<div className="evo-report-section">
<h5>概览</h5>
<div className="evo-report-stats-grid">
<div className="evo-report-stat">
<span className="evo-report-stat-value">{reportData.new_skills || 0}</span>
<span className="evo-report-stat-label">新增技能</span>
</div>
<div className="evo-report-stat">
<span className="evo-report-stat-value">{reportData.promoted_skills || 0}</span>
<span className="evo-report-stat-label">技能升级</span>
</div>
<div className="evo-report-stat">
<span className="evo-report-stat-value">{(reportData.total_calls || 0).toLocaleString()}</span>
<span className="evo-report-stat-label">总调用次数</span>
</div>
</div>
</div>
{reportData.top_skills && reportData.top_skills.length > 0 && (
<div className="evo-report-section">
<h5>最活跃技能 TOP5</h5>
{reportData.top_skills.map((s: any, i: number) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', fontSize: 'var(--text-sm)' }}>
<span>{i + 1}. {s.skill_name}</span>
<span style={{ color: 'var(--color-text-tertiary)' }}>{s.use_count}次</span>
</div>
))}
</div>
)}
{reportData.level_change && (
<div className="evo-report-section">
<h5>等级变化</h5>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)' }}>
进化等级: Lv.{reportData.level_change.from || '?'} → Lv.{reportData.level_change.to || '?'}
</p>
</div>
)}
</div>
</div>
</div>
) : loadingReports ? (
<p style={{ textAlign: 'center', padding: 'var(--space-3xl)', color: 'var(--color-text-tertiary)' }}>加载中...</p>
) : (
<div className="evo-reports-list">
{reports.length > 0 ? reports.map(r => (
<div key={r.id} className="evo-report-card" onClick={() => handleViewReport(r.id)}>
<div className="evo-report-header">
<span className="evo-report-type">{r.report_type === 'weekly' ? '📊 周报' : '📈 月报'}</span>
<span className="evo-report-period">{r.period_start} ~ {r.period_end}</span>
</div>
<div className="evo-report-summary">
{r.report_data?.summary || `${r.report_type === 'weekly' ? '本周' : '本月'}进化报告`}
</div>
</div>
)) : (
<p style={{ textAlign: 'center', padding: 'var(--space-3xl)', color: 'var(--color-text-tertiary)' }}>
暂无进化报告。系统将在每周一自动生成周报。
</p>
)}
</div>
)}
</div>
);
};
// ==================== Main Dashboard ====================
const EvolutionDashboard: React.FC = () => {
const [activePanel, setActivePanel] = useState<string>('skill-tree');
const [skills, setSkills] = useState<EvolutionSkill[]>([]);
const [events, setEvents] = useState<EvolutionEvent[]>([]);
const [stats, setStats] = useState<StatisticsData | null>(null);
const [profile, setProfile] = useState<EvolutionProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
const [animEnabled, setAnimEnabled] = useState(() => {
return localStorage.getItem('evo_anim_enabled') !== 'false';
});
const totalSkills = skills.length;
const mainCount = skills.filter(s => s.state === 'main').length;
const babyCount = skills.filter(s => s.state === 'baby').length;
const toggleAnim = useCallback(() => {
setAnimEnabled(prev => {
const next = !prev;
localStorage.setItem('evo_anim_enabled', String(next));
return next;
});
}, []);
const handleViewSkill = (skillName: string) => {
const skill = skills.find(s => s.skill_name === skillName);
if (skill) {
setGlobalSelectedSkill(skill);
}
};
const loadDashboardData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [skillsRes, eventsRes, statsRes, profileRes] = await Promise.all([
evolutionAPI.getSkills().catch(() => null),
evolutionAPI.getEvents().catch(() => null),
evolutionAPI.getStatistics().catch(() => null),
evolutionAPI.getProfile().catch(() => null),
]);
// Use response data if available, else fallback to empty
if (skillsRes?.data) {
const sdata = skillsRes.data as any;
setSkills(Array.isArray(sdata) ? sdata : (sdata?.skills || []));
}
if (eventsRes?.data) {
const edata = eventsRes.data as any;
setEvents(Array.isArray(edata) ? edata : (edata?.events || []));
}
if (statsRes?.data) setStats(statsRes.data);
if (profileRes?.data) setProfile(profileRes.data);
} catch {
setError('加载进化数据失败,请检查网络连接后重试');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadDashboardData(); }, [loadDashboardData]);
if (loading) {
return (
<div className="evo-dashboard">
<div className="evo-loading"><div className="evo-loading-spinner" /><p>正在加载进化数据...</p></div>
</div>
);
}
if (error) {
return (
<div className="evo-dashboard">
<div className="evo-error"><p>{error}</p><button className="evo-btn evo-btn-outline" onClick={loadDashboardData}>重试</button></div>
</div>
);
}
return (
<div className="evo-dashboard">
<header className="evo-header">
<div className="evo-header-left">
<div className="evo-header-icon">
<Icon size={20}><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/><path d="M0 0" opacity="0"/></Icon>
</div>
<div>
<h1 className="evo-header-title">AI进化驾驶舱</h1>
<p className="evo-header-sub">你的专属AI正在持续进化中</p>
</div>
</div>
<div className="evo-header-stats">
<div className="evo-header-stat"><span className="evo-hs-num">{totalSkills}</span><span className="evo-hs-label">技能总数</span></div>
<div className="evo-header-stat"><span className="evo-hs-num" style={{ color: '#219B5E' }}>{mainCount}</span><span className="evo-hs-label">已成熟</span></div>
<div className="evo-header-stat"><span className="evo-hs-num" style={{ color: '#E8922C' }}>{babyCount}</span><span className="evo-hs-label">萌芽中</span></div>
</div>
</header>
{/* Global skill detail modal */}
{globalSelectedSkill && (
<div className="evo-skill-detail-modal" onClick={() => setGlobalSelectedSkill(null)}>
<div className="evo-skill-detail-card" onClick={e => e.stopPropagation()}>
<div className="evo-skill-detail-header">
<span className={`evo-state-badge ${globalSelectedSkill.state}`}>{stateConfig[globalSelectedSkill.state].label}</span>
<h3>{globalSelectedSkill.skill_name}</h3>
<button className="evo-close-btn" onClick={() => setGlobalSelectedSkill(null)}>
<Icon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></Icon>
</button>
</div>
<p className="evo-skill-detail-desc">{globalSelectedSkill.description}</p>
<div className="evo-skill-detail-meta">
<div className="evo-meta-item"><span className="evo-meta-label">版本</span><span>v{globalSelectedSkill.version}</span></div>
<div className="evo-meta-item"><span className="evo-meta-label">使用次数</span><span>{globalSelectedSkill.use_count.toLocaleString()}</span></div>
<div className="evo-meta-item"><span className="evo-meta-label">分类</span><span>{globalSelectedSkill.category}</span></div>
<div className="evo-meta-item"><span className="evo-meta-label">创建日期</span><span>{new Date(globalSelectedSkill.created_at).toLocaleDateString()}</span></div>
</div>
<div className="evo-skill-detail-tags">
{globalSelectedSkill.tags.map(t => <span key={t} className="evo-tag">{t}</span>)}
</div>
<div className="evo-skill-detail-actions">
<button className="evo-btn evo-btn-outline evo-btn-sm">编辑技能</button>
<button className="evo-btn evo-btn-outline evo-btn-sm" onClick={() => { /* navigate to skill-tree panel first */ setActivePanel('skill-tree'); }}>版本历史</button>
<button className="evo-btn evo-btn-outline evo-btn-sm">来源对话</button>
</div>
</div>
</div>
)}
<div className="evo-body">
<aside className="evo-sidebar">
{Object.keys(panelLabels).map(key => (
<button key={key} className={`evo-sidebar-item ${activePanel === key ? 'active' : ''}`} onClick={() => setActivePanel(key)}>
{panelIcons[key]}<span>{panelLabels[key]}</span>
</button>
))}
</aside>
<main className="evo-main">
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} animEnabled={animEnabled} onToggleAnim={toggleAnim} />}
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
{activePanel === 'reports' && <ReportsPanel />}
</main>
</div>
</div>
);
};
export default EvolutionDashboard;