Spaces:
Sleeping
Sleeping
| <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 ; } | |
| .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> | |