新增 src/utils/datetime.ts 共享 formatBjTime 工具, 13 个页面(11后台+2前台) 的 toLocaleString/formatDate 改为显式北京时间。Dashboard.tsx 保留图表标签的 formatDate(split) 不受影响。
1086 lines
54 KiB
TypeScript
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;
|