Trae Bot
Update Tokenomics Forge: UI overhaul, bug fixes, localization, and HF config
0387db2
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tokenomics Forge | 代币经济模型锻造工场</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
850: '#1f2937',
900: '#111827',
950: '#030712',
},
tech: {
500: '#6366f1',
glow: 'rgba(99, 102, 241, 0.5)'
}
},
boxShadow: {
'glow': '0 0 15px rgba(99, 102, 241, 0.3)',
'glow-lg': '0 0 25px rgba(99, 102, 241, 0.4)',
}
}
}
}
</script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #030712;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
body {
background-color: #030712;
background-image:
radial-gradient(circle at 15% 50%, rgba(99, 102, 241, 0.08) 0%, transparent 25%),
radial-gradient(circle at 85% 30%, rgba(14, 165, 233, 0.08) 0%, transparent 25%);
}
.glass-panel {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(75, 85, 99, 0.4);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.input-tech {
background: rgba(31, 41, 55, 0.6);
border: 1px solid rgba(75, 85, 99, 0.5);
transition: all 0.3s ease;
}
.input-tech:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
background: rgba(31, 41, 55, 0.9);
}
.btn-tech {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.3);
transition: all 0.3s ease;
}
.btn-tech:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
}
.btn-tech:active {
transform: translateY(0);
}
[v-cloak] { display: none !important; }
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
</head>
<body class="bg-gray-950 text-gray-100 min-h-screen font-sans overflow-hidden">
<div id="app" v-cloak class="flex flex-col h-screen">
<!-- Navbar -->
<header class="h-16 border-b border-gray-800 flex items-center justify-between px-6 bg-gray-900/80 backdrop-blur-md z-20">
<div class="flex items-center gap-3">
<div class="relative">
<i class="fa-solid fa-hammer text-indigo-500 text-xl relative z-10"></i>
<div class="absolute inset-0 blur-sm bg-indigo-500/50"></div>
</div>
<h1 class="text-xl font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-cyan-400">
TOKENOMICS FORGE
</h1>
<span class="text-[10px] text-indigo-300 px-1.5 py-0.5 border border-indigo-500/30 rounded bg-indigo-500/10">v2.0</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 bg-gray-800/50 rounded-lg p-1 border border-gray-700/50">
<button @click="triggerImport" class="px-3 py-1.5 text-xs rounded hover:bg-gray-700 transition flex items-center gap-2 text-gray-300">
<i class="fa-solid fa-file-import"></i> 导入项目
</button>
<input type="file" ref="fileInput" @change="handleImport" class="hidden" accept=".json">
<button @click="loadExample" class="px-3 py-1.5 text-xs rounded hover:bg-gray-700 transition flex items-center gap-2 text-gray-300">
<i class="fa-solid fa-rotate-right"></i> 重置示例
</button>
<button @click="saveProject" class="px-3 py-1.5 text-xs btn-tech rounded text-white font-medium flex items-center gap-2">
<i class="fa-solid fa-save"></i> 保存项目
</button>
<button @click="showProjects = true" class="px-3 py-1.5 text-xs bg-gray-700 hover:bg-gray-600 rounded text-white transition">
<i class="fa-solid fa-folder-open"></i>
</button>
</div>
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Panel: Configuration -->
<div class="w-1/3 min-w-[420px] max-w-[500px] border-r border-gray-800 flex flex-col bg-gray-900/40 overflow-y-auto backdrop-blur-sm custom-scrollbar">
<!-- Basic Info -->
<div class="p-6 border-b border-gray-800/60">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-indigo-400 uppercase tracking-wider flex items-center gap-2">
<i class="fa-solid fa-cube"></i> 基础信息
</h2>
<!-- Logo Upload -->
<div class="relative group cursor-pointer" @click="$refs.logoInput.click()">
<div class="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center overflow-hidden hover:border-indigo-500 transition">
<img v-if="project.logo" :src="project.logo" class="w-full h-full object-cover">
<i v-else class="fa-solid fa-camera text-gray-500 text-xs"></i>
</div>
<input type="file" ref="logoInput" @change="handleLogoUpload" class="hidden" accept="image/*">
<div class="absolute bottom-0 right-0 w-3 h-3 bg-indigo-500 rounded-full border border-gray-900"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 mb-1.5">代币名称</label>
<input v-model="project.tokenName" type="text" class="w-full input-tech rounded px-3 py-2 text-sm text-gray-200 focus:outline-none placeholder-gray-600" placeholder="例如: Bitcoin">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1.5">代币符号 (Ticker)</label>
<input v-model="project.tokenSymbol" type="text" class="w-full input-tech rounded px-3 py-2 text-sm text-gray-200 focus:outline-none placeholder-gray-600" placeholder="例如: BTC">
</div>
<div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1.5">总供应量 (Total Supply)</label>
<div class="relative">
<input v-model.number="project.totalSupply" type="number" class="w-full input-tech rounded px-3 py-2 text-sm text-gray-200 focus:outline-none font-mono" min="0">
<div class="absolute right-3 top-2 text-xs text-indigo-400 font-mono pointer-events-none">${ formatNumber(project.totalSupply) }</div>
</div>
</div>
</div>
</div>
<!-- Allocations -->
<div class="p-6 flex-1">
<div class="flex justify-between items-center mb-4">
<h2 class="text-sm font-semibold text-indigo-400 uppercase tracking-wider flex items-center gap-2">
<i class="fa-solid fa-chart-pie"></i> 分配方案
</h2>
<button @click="addAllocation" class="text-xs bg-indigo-500/10 text-indigo-400 hover:bg-indigo-500/20 px-2 py-1 rounded transition border border-indigo-500/30">
<i class="fa-solid fa-plus mr-1"></i> 添加分配
</button>
</div>
<!-- Total % Check -->
<div class="mb-5 text-xs flex justify-between px-3 py-2.5 rounded border backdrop-blur-sm transition-all duration-300"
:class="Math.abs(totalPercent - 100) < 0.01 ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-red-500/10 border-red-500/30 text-red-400'">
<div class="flex items-center gap-2">
<i class="fa-solid" :class="Math.abs(totalPercent - 100) < 0.01 ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
<span class="font-medium">已分配: ${ totalPercent.toFixed(2) }%</span>
</div>
<span v-if="Math.abs(totalPercent - 100) >= 0.01" class="font-mono">剩余: ${ (100 - totalPercent).toFixed(2) }%</span>
<span v-else class="font-bold tracking-wider">PERFECT</span>
</div>
<div class="space-y-4 pb-10">
<transition-group name="list">
<div v-for="(alloc, index) in project.allocations" :key="index" class="glass-panel rounded-lg p-4 relative group hover:border-indigo-500/30 transition-all duration-300">
<button @click="removeAllocation(index)" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500/20 text-red-400 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition hover:bg-red-500 hover:text-white">
<i class="fa-solid fa-times text-xs"></i>
</button>
<div class="grid grid-cols-12 gap-3 mb-4">
<div class="col-span-8">
<label class="text-[10px] text-gray-500 uppercase mb-0.5 block">名称</label>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getColor(index) }"></div>
<input v-model="alloc.name" type="text" placeholder="类别名称" class="w-full bg-transparent border-b border-gray-700 text-sm font-bold text-gray-200 focus:outline-none focus:border-indigo-500 px-1 py-0.5 placeholder-gray-700">
</div>
</div>
<div class="col-span-4">
<label class="text-[10px] text-gray-500 uppercase mb-0.5 block text-right">占比 (%)</label>
<div class="flex items-center">
<input v-model.number="alloc.percent" type="number" step="0.1" class="w-full bg-gray-800/50 border border-gray-700 rounded px-2 py-1 text-right text-sm text-indigo-300 focus:border-indigo-500 focus:outline-none font-mono">
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-3 text-xs bg-gray-900/30 p-2 rounded border border-gray-800/50">
<div>
<label class="text-gray-500 block mb-1 text-[10px]" title="Token Generation Event Unlock">TGE 解锁 %</label>
<input v-model.number="alloc.tge" type="number" class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 focus:border-indigo-500 focus:outline-none font-mono text-gray-300">
</div>
<div>
<label class="text-gray-500 block mb-1 text-[10px]" title="Cliff Period in Months">锁仓期 (月)</label>
<input v-model.number="alloc.cliff" type="number" class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 focus:border-indigo-500 focus:outline-none font-mono text-gray-300">
</div>
<div>
<label class="text-gray-500 block mb-1 text-[10px]" title="Linear Vesting in Months">释放期 (月)</label>
<input v-model.number="alloc.vesting" type="number" class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 focus:border-indigo-500 focus:outline-none font-mono text-gray-300">
</div>
</div>
<div class="mt-3 pt-2 border-t border-gray-800/50 text-[10px] text-gray-500 flex justify-between font-mono">
<span>总量: ${ formatCompact(project.totalSupply * alloc.percent / 100) }</span>
<span class="text-indigo-400">首日: ${ formatCompact(project.totalSupply * alloc.percent / 100 * alloc.tge / 100) }</span>
</div>
</div>
</transition-group>
</div>
</div>
</div>
<!-- Right Panel: Visualization -->
<div class="flex-1 flex flex-col bg-gray-950 p-6 overflow-y-auto custom-scrollbar relative">
<!-- Decorative Background Elements -->
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-indigo-500/5 rounded-full blur-[100px] pointer-events-none"></div>
<!-- Top Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6 h-[320px] relative z-10">
<!-- Pie Chart -->
<div class="glass-panel rounded-xl p-5 flex flex-col">
<h3 class="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<i class="fa-solid fa-chart-pie text-indigo-500"></i> 代币分配图表
</h3>
<div id="pieChart" class="flex-1 w-full h-full"></div>
</div>
<!-- Stats Card -->
<div class="glass-panel rounded-xl p-6 flex flex-col justify-center space-y-8">
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">初始流通量 (Initial Circulating)</h3>
<div class="text-3xl font-bold text-white tracking-tight font-mono">${ formatNumber(initialCirculating) }</div>
<div class="text-xs text-indigo-400 mt-1 flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-indigo-400 animate-pulse"></div>
占总量的 ${ ((initialCirculating / (project.totalSupply || 1)) * 100).toFixed(2) }%
</div>
</div>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">完全稀释估值 (FDV)</h3>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xl">$</span>
<input v-model.number="tokenPrice" type="number" step="0.0001" class="bg-transparent border-b border-gray-700 w-32 text-2xl font-bold focus:outline-none focus:border-green-500 text-green-400 font-mono">
</div>
<div class="text-xs text-gray-500 mt-1 font-mono">≈ $${ formatCompact(project.totalSupply * tokenPrice) }</div>
</div>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
模拟时长: <span class="text-white">${ simulationMonths } 个月</span>
</h3>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-600">12</span>
<input type="range" v-model.number="simulationMonths" min="12" max="120" step="6" class="flex-1 h-1.5 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-indigo-500">
<span class="text-xs text-gray-600">120</span>
</div>
</div>
</div>
<!-- Summary Text -->
<div class="glass-panel rounded-xl p-5 overflow-y-auto text-sm text-gray-400 leading-relaxed custom-scrollbar">
<h3 class="text-xs font-semibold text-gray-400 mb-3 flex items-center gap-2">
<i class="fa-solid fa-file-lines text-indigo-500"></i> 模型摘要
</h3>
<div class="space-y-3 text-xs">
<p>
<span class="text-indigo-300 font-bold">${ project.tokenName }</span> (${ project.tokenSymbol }) 的代币经济模型设计显示,
总供应量设定为 <span class="text-white font-mono bg-gray-800 px-1 rounded">${ formatCompact(project.totalSupply) }</span> 枚。
</p>
<div class="p-3 bg-gray-900/50 rounded border border-gray-800">
<div class="flex justify-between mb-1">
<span>TGE 解锁:</span>
<span class="text-white font-mono">${ formatCompact(initialCirculating) }</span>
</div>
<div class="w-full bg-gray-800 h-1.5 rounded-full overflow-hidden">
<div class="bg-indigo-500 h-full" :style="{ width: Math.min((initialCirculating/project.totalSupply)*100, 100) + '%' }"></div>
</div>
</div>
<p>
最大通胀压力通常发生在 TGE 后 6-12 个月(即锁仓期结束期)。
完全解锁预计需要 <span class="text-white font-mono">${ maxVestingMonth }</span> 个月。
</p>
</div>
</div>
</div>
<!-- Main Schedule Chart -->
<div class="flex-1 glass-panel rounded-xl p-5 flex flex-col min-h-[400px] relative z-10">
<h3 class="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<i class="fa-solid fa-chart-line text-indigo-500"></i> 释放曲线 (Vesting Schedule)
</h3>
<div id="lineChart" class="flex-1 w-full h-full"></div>
</div>
</div>
</div>
<!-- Project Modal -->
<transition name="fade">
<div v-if="showProjects" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-gray-900 border border-gray-700 rounded-xl w-[500px] max-h-[80vh] flex flex-col shadow-2xl shadow-indigo-500/20">
<div class="p-4 border-b border-gray-800 flex justify-between items-center bg-gray-850 rounded-t-xl">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<i class="fa-solid fa-database text-indigo-500"></i> 项目存档
</h3>
<button @click="showProjects = false" class="text-gray-500 hover:text-white transition w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-700">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="p-4 overflow-y-auto flex-1 custom-scrollbar">
<div v-if="savedProjects.length === 0" class="text-center text-gray-500 py-12 flex flex-col items-center">
<i class="fa-solid fa-folder-open text-4xl mb-3 opacity-30"></i>
<p>暂无存档项目</p>
</div>
<div v-for="p in savedProjects" :key="p.id" class="flex justify-between items-center p-4 hover:bg-gray-800/80 rounded-lg border border-transparent hover:border-gray-700 mb-3 group transition-all duration-200">
<div>
<div class="font-bold text-indigo-300 text-lg">${ p.name }</div>
<div class="text-xs text-gray-500 mt-1 flex items-center gap-2">
<i class="fa-regular fa-clock"></i> ${ p.updated_at || 'Recently' }
</div>
</div>
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition transform translate-x-2 group-hover:translate-x-0">
<button @click="loadProject(p.id)" class="px-3 py-1.5 bg-indigo-900/50 text-indigo-300 border border-indigo-500/30 rounded text-xs hover:bg-indigo-500 hover:text-white transition">
加载
</button>
<button @click="deleteProject(p.id)" class="px-3 py-1.5 bg-red-900/30 text-red-400 border border-red-500/30 rounded text-xs hover:bg-red-600 hover:text-white transition">
删除
</button>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<script>
const { createApp, ref, reactive, computed, watch, onMounted, nextTick } = Vue;
createApp({
delimiters: ['${', '}'], // Changed delimiters to avoid Jinja2 conflict
setup() {
const showProjects = ref(false);
const savedProjects = ref([]);
const tokenPrice = ref(0.1);
const simulationMonths = ref(60);
const fileInput = ref(null);
const logoInput = ref(null);
// Default enriched data
const defaultProject = {
tokenName: 'Tokenomics Token',
tokenSymbol: 'TKN',
totalSupply: 1000000000,
logo: null,
allocations: [
{ name: 'Seed Round', percent: 10, tge: 5, cliff: 12, vesting: 18 },
{ name: 'Public Sale', percent: 5, tge: 20, cliff: 0, vesting: 6 },
{ name: 'Team', percent: 15, tge: 0, cliff: 12, vesting: 36 },
{ name: 'Advisors', percent: 5, tge: 0, cliff: 6, vesting: 24 },
{ name: 'Ecosystem', percent: 30, tge: 5, cliff: 0, vesting: 48 },
{ name: 'Treasury', percent: 20, tge: 0, cliff: 12, vesting: 48 },
{ name: 'Liquidity', percent: 15, tge: 50, cliff: 0, vesting: 12 },
]
};
const project = reactive(JSON.parse(JSON.stringify(defaultProject)));
project.id = null;
let pieChart = null;
let lineChart = null;
const colors = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6'
];
const getColor = (index) => colors[index % colors.length];
const totalPercent = computed(() => {
return project.allocations.reduce((sum, item) => sum + (item.percent || 0), 0);
});
const initialCirculating = computed(() => {
return project.allocations.reduce((sum, item) => {
const amount = project.totalSupply * (item.percent / 100);
return sum + (amount * (item.tge / 100));
}, 0);
});
const maxVestingMonth = computed(() => {
if (project.allocations.length === 0) return 0;
return Math.max(...project.allocations.map(a => (a.cliff || 0) + (a.vesting || 0)));
});
// Formatters
const formatNumber = (num) => {
return new Intl.NumberFormat('en-US').format(Math.round(num));
};
const formatCompact = (num) => {
return new Intl.NumberFormat('en-US', { notation: "compact", maximumFractionDigits: 1 }).format(num);
};
// Actions
const addAllocation = () => {
project.allocations.push({
name: 'New Allocation',
percent: 0,
tge: 0,
cliff: 0,
vesting: 12
});
};
const removeAllocation = (index) => {
project.allocations.splice(index, 1);
};
const loadExample = () => {
Object.assign(project, JSON.parse(JSON.stringify(defaultProject)));
project.id = null; // Reset ID for new save
simulationMonths.value = 60;
};
// API Interactions
const fetchProjects = async () => {
try {
const res = await fetch('/api/projects');
if (res.ok) {
savedProjects.value = await res.json();
}
} catch (e) {
console.error('Failed to fetch projects', e);
}
};
const saveProject = async () => {
const payload = {
...project,
updated_at: new Date().toLocaleString('zh-CN')
};
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
project.id = data.id;
fetchProjects();
alert('项目保存成功!');
}
} catch (e) {
alert('保存失败: ' + e.message);
}
};
const loadProject = async (id) => {
try {
const res = await fetch(`/api/projects/${id}`);
if (res.ok) {
const data = await res.json();
Object.assign(project, data);
showProjects.value = false;
}
} catch (e) {
alert('加载失败');
}
};
const deleteProject = async (id) => {
if (!confirm('确定要删除这个项目吗?')) return;
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (res.ok) {
fetchProjects();
}
} catch (e) {
alert('删除失败');
}
};
// File Uploads
const triggerImport = () => {
fileInput.value.click();
};
const handleImport = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
// Basic validation
if (data.tokenName && data.allocations) {
Object.assign(project, data);
project.id = null; // New import, new ID
alert('导入成功!');
} else {
alert('文件格式不正确:缺少必要字段');
}
} catch (err) {
alert('JSON 解析失败');
}
};
reader.readAsText(file);
event.target.value = ''; // Reset
};
const handleLogoUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Client-side preview immediately
const reader = new FileReader();
reader.onload = (e) => {
project.logo = e.target.result;
};
reader.readAsDataURL(file);
// Optional: Upload to server if you want persistent URL
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
project.logo = data.url; // Use server URL
}
} catch (e) {
console.error('Upload failed', e);
}
event.target.value = '';
};
// Chart Logic
const initCharts = () => {
if (!document.getElementById('pieChart')) return;
pieChart = echarts.init(document.getElementById('pieChart'));
lineChart = echarts.init(document.getElementById('lineChart'));
window.addEventListener('resize', () => {
pieChart && pieChart.resize();
lineChart && lineChart.resize();
});
};
const updateCharts = () => {
if (!pieChart || !lineChart) return;
// Pie Chart
pieChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(17, 24, 39, 0.9)',
borderColor: '#374151',
textStyle: { color: '#f3f4f6' }
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { color: '#9ca3af' },
itemWidth: 10,
itemHeight: 10
},
series: [{
name: 'Token Allocation',
type: 'pie',
radius: ['45%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#030712',
borderWidth: 3
},
label: { show: false },
data: project.allocations.map((a, i) => ({
value: a.percent,
name: a.name,
itemStyle: { color: getColor(i) }
}))
}]
});
// Line Chart
const months = simulationMonths.value;
const timeline = Array.from({length: months + 1}, (_, i) => i);
const series = project.allocations.map((alloc, i) => {
const data = timeline.map(month => {
const totalAmount = (project.totalSupply || 0) * ((alloc.percent || 0) / 100);
const tgeAmount = totalAmount * ((alloc.tge || 0) / 100);
let unlocked = 0;
if (month === 0) {
unlocked = tgeAmount;
} else {
unlocked = tgeAmount;
const cliff = alloc.cliff || 0;
const vesting = alloc.vesting || 0;
if (month > cliff) {
if (vesting > 0) {
const vestingMonthsPassed = Math.min(month - cliff, vesting);
const vestingAmount = (totalAmount - tgeAmount) * (vestingMonthsPassed / vesting);
unlocked += vestingAmount;
} else {
unlocked += (totalAmount - tgeAmount);
}
}
}
return Math.min(unlocked, totalAmount);
});
return {
name: alloc.name,
type: 'line',
stack: 'Total',
areaStyle: { opacity: 0.3 },
lineStyle: { width: 0 },
showSymbol: false,
itemStyle: { color: getColor(i) },
emphasis: { focus: 'series' },
data: data
};
});
lineChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
backgroundColor: 'rgba(17, 24, 39, 0.9)',
borderColor: '#374151',
textStyle: { color: '#f3f4f6' },
formatter: function(params) {
let res = `Month ${params[0].axisValue}<br/>`;
let total = 0;
params.forEach(item => {
res += `<div style="display:flex;justify-content:space-between;width:200px">
<span><span style="display:inline-block;margin-right:5px;border-radius:10px;width:9px;height:9px;background-color:${item.color}"></span>${item.seriesName}</span>
<span style="font-family:monospace">${formatCompact(item.value)}</span>
</div>`;
total += item.value;
});
res += `<div style="border-top:1px solid #374151;margin-top:5px;padding-top:5px;display:flex;justify-content:space-between">
<span class="font-bold">Total Circulating</span>
<span class="font-bold font-mono text-indigo-400">${formatCompact(total)}</span>
</div>`;
return res;
}
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: timeline,
axisLabel: { color: '#6b7280' },
axisLine: { lineStyle: { color: '#374151' } }
},
yAxis: {
type: 'value',
axisLabel: {
color: '#6b7280',
formatter: (value) => formatCompact(value)
},
splitLine: { lineStyle: { color: '#1f2937' } }
},
series: series
});
};
// Watchers
watch([project, simulationMonths], () => {
nextTick(() => updateCharts());
}, { deep: true });
onMounted(() => {
initCharts();
updateCharts();
fetchProjects();
});
return {
project,
showProjects,
savedProjects,
tokenPrice,
simulationMonths,
totalPercent,
initialCirculating,
maxVestingMonth,
fileInput,
logoInput,
formatNumber,
formatCompact,
addAllocation,
removeAllocation,
saveProject,
loadProject,
deleteProject,
triggerImport,
handleImport,
handleLogoUpload,
loadExample,
getColor
};
}
}).mount('#app');
</script>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
</style>
</body>
</html>