Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Changelog Studio - 优雅的发布日志生成器</title> | |
| <script src="/static/js/tailwindcss.js"></script> | |
| <script src="/static/js/vue.global.js"></script> | |
| <script src="/static/js/html2canvas.min.js"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| .font-mono { font-family: 'JetBrains Mono', monospace; } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } | |
| /* Theme Styles */ | |
| .theme-modern .preview-card { background: white; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } | |
| .theme-modern .tag-feature { background: #eff6ff; color: #2563eb; } | |
| .theme-modern .tag-fix { background: #fef2f2; color: #dc2626; } | |
| .theme-modern .tag-improve { background: #f0fdf4; color: #16a34a; } | |
| .theme-dark .preview-wrapper { background-color: #0f172a; } | |
| .theme-dark .preview-card { background: #1e293b; color: #f8fafc; border: 1px solid #334155; } | |
| .theme-dark .text-muted { color: #94a3b8; } | |
| .theme-dark .tag-feature { background: #1e3a8a; color: #93c5fd; } | |
| .theme-dark .tag-fix { background: #7f1d1d; color: #fca5a5; } | |
| .theme-dark .tag-improve { background: #14532d; color: #86efac; } | |
| .theme-minimal .preview-card { background: white; border: 2px solid #000; box-shadow: 8px 8px 0px #000; border-radius: 0; } | |
| .theme-minimal .preview-header { border-bottom: 2px solid #000; padding-bottom: 1rem; } | |
| .theme-minimal .tag-base { border: 1px solid #000; background: transparent; color: #000; font-weight: bold; } | |
| .theme-vibrant .preview-wrapper { background: linear-gradient(135deg, #8b5cf6, #ec4899); } | |
| .theme-vibrant .preview-card { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.5); } | |
| </style> | |
| </head> | |
| <body class="bg-slate-50 text-slate-800 h-screen flex flex-col overflow-hidden"> | |
| <div id="app" class="flex h-full"> | |
| <!-- Sidebar / Editor --> | |
| <div class="w-1/3 min-w-[400px] bg-white border-r border-slate-200 flex flex-col h-full z-10 shadow-xl"> | |
| <div class="p-6 border-b border-slate-100 flex items-center justify-between bg-white"> | |
| <div class="flex items-center gap-2 text-indigo-600"> | |
| <i class="fa-solid fa-layer-group text-2xl"></i> | |
| <h1 class="text-xl font-bold tracking-tight text-slate-900">Changelog Studio</h1> | |
| </div> | |
| <div class="text-xs font-mono bg-slate-100 px-2 py-1 rounded text-slate-500">v1.0</div> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-6 space-y-8"> | |
| <!-- Basic Info --> | |
| <section> | |
| <h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">基本信息</h3> | |
| <div class="space-y-4"> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label> | |
| <input v-model="project.name" type="text" class="w-full rounded-lg border-slate-300 border px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1">Logo Emoji</label> | |
| <input v-model="project.logoEmoji" type="text" class="w-full rounded-lg border-slate-300 border px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition text-center"> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1">版本号</label> | |
| <input v-model="project.version" type="text" class="w-full rounded-lg border-slate-300 border px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition font-mono"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1">发布日期</label> | |
| <input v-model="project.date" type="date" class="w-full rounded-lg border-slate-300 border px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Theme Selection --> | |
| <section> | |
| <h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">设计风格</h3> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <button @click="theme = 'modern'" :class="{'ring-2 ring-indigo-500 bg-indigo-50': theme === 'modern'}" class="flex items-center gap-2 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 transition text-left"> | |
| <div class="w-4 h-4 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500"></div> | |
| <span class="text-sm font-medium">Modern 现代</span> | |
| </button> | |
| <button @click="theme = 'dark'" :class="{'ring-2 ring-indigo-500 bg-indigo-50': theme === 'dark'}" class="flex items-center gap-2 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 transition text-left"> | |
| <div class="w-4 h-4 rounded-full bg-slate-800"></div> | |
| <span class="text-sm font-medium">Dark 极客</span> | |
| </button> | |
| <button @click="theme = 'minimal'" :class="{'ring-2 ring-indigo-500 bg-indigo-50': theme === 'minimal'}" class="flex items-center gap-2 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 transition text-left"> | |
| <div class="w-4 h-4 rounded-full bg-white border border-black"></div> | |
| <span class="text-sm font-medium">Minimal 极简</span> | |
| </button> | |
| <button @click="theme = 'vibrant'" :class="{'ring-2 ring-indigo-500 bg-indigo-50': theme === 'vibrant'}" class="flex items-center gap-2 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 transition text-left"> | |
| <div class="w-4 h-4 rounded-full bg-gradient-to-br from-pink-500 to-orange-400"></div> | |
| <span class="text-sm font-medium">Vibrant 活力</span> | |
| </button> | |
| </div> | |
| </section> | |
| <!-- Content Editor --> | |
| <section> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wider">更新内容</h3> | |
| <div class="flex gap-2"> | |
| <button @click="addSection('feature')" class="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100 transition">+ Feature</button> | |
| <button @click="addSection('fix')" class="text-xs bg-red-50 text-red-600 px-2 py-1 rounded hover:bg-red-100 transition">+ Fix</button> | |
| <button @click="addSection('improve')" class="text-xs bg-green-50 text-green-600 px-2 py-1 rounded hover:bg-green-100 transition">+ Improve</button> | |
| </div> | |
| </div> | |
| <div class="space-y-6"> | |
| <div v-for="(section, sIndex) in sections" :key="sIndex" class="relative group border border-slate-200 rounded-lg p-4 bg-slate-50/50"> | |
| <button @click="removeSection(sIndex)" class="absolute top-2 right-2 text-slate-300 hover:text-red-500 transition opacity-0 group-hover:opacity-100"> | |
| <i class="fa-solid fa-trash"></i> | |
| </button> | |
| <div class="flex items-center gap-2 mb-3"> | |
| <span v-if="section.type === 'feature'" class="text-lg">✨</span> | |
| <span v-else-if="section.type === 'fix'" class="text-lg">🐛</span> | |
| <span v-else-if="section.type === 'improve'" class="text-lg">🚀</span> | |
| <span v-else class="text-lg">📝</span> | |
| <input v-model="section.title" class="bg-transparent font-medium text-slate-700 outline-none border-b border-transparent focus:border-indigo-300 w-full" placeholder="Section Title"> | |
| </div> | |
| <div class="space-y-2 pl-7"> | |
| <div v-for="(item, iIndex) in section.items" :key="iIndex" class="flex items-start gap-2 group/item"> | |
| <div class="mt-1.5 w-1.5 h-1.5 rounded-full bg-slate-300 shrink-0"></div> | |
| <input v-model="item.text" @keydown.enter.prevent="addItem(sIndex)" class="w-full bg-transparent text-sm text-slate-600 outline-none border-b border-transparent focus:border-slate-300 pb-0.5" placeholder="Item description..."> | |
| <button @click="removeItem(sIndex, iIndex)" class="text-slate-300 hover:text-red-400 opacity-0 group-hover/item:opacity-100 transition"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| <button @click="addItem(sIndex)" class="text-xs text-indigo-500 hover:text-indigo-700 mt-2 font-medium flex items-center gap-1"> | |
| <i class="fa-solid fa-plus"></i> 添加条目 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Footer Actions --> | |
| <div class="p-6 border-t border-slate-200 bg-slate-50 flex gap-3"> | |
| <button @click="copyMarkdown" class="flex-1 py-2.5 px-4 bg-white border border-slate-300 rounded-lg text-slate-700 font-medium hover:bg-slate-50 hover:text-indigo-600 transition shadow-sm flex items-center justify-center gap-2"> | |
| <i class="fa-brands fa-markdown"></i> 复制 MD | |
| </button> | |
| <button @click="downloadImage" class="flex-1 py-2.5 px-4 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition shadow-md shadow-indigo-200 flex items-center justify-center gap-2"> | |
| <i class="fa-solid fa-download"></i> 下载图片 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Main Preview Area --> | |
| <div class="flex-1 bg-slate-100 flex items-center justify-center p-10 overflow-hidden relative" :class="`theme-${theme}`"> | |
| <!-- Background Pattern for Presentation --> | |
| <div class="absolute inset-0 opacity-50 pointer-events-none preview-wrapper transition-colors duration-500" | |
| :class="{ | |
| 'bg-slate-100': theme === 'modern' || theme === 'minimal', | |
| 'bg-slate-900': theme === 'dark', | |
| 'bg-gradient-to-br from-indigo-500 to-purple-500': theme === 'vibrant' | |
| }"> | |
| <div v-if="theme === 'modern' || theme === 'minimal'" class="absolute inset-0" style="background-image: radial-gradient(#cbd5e1 1px, transparent 1px); background-size: 24px 24px;"></div> | |
| </div> | |
| <!-- The Card --> | |
| <div ref="captureArea" class="relative z-10 w-full max-w-2xl transition-all duration-300 transform hover:scale-[1.01]"> | |
| <div class="preview-card p-10 min-h-[400px] flex flex-col relative overflow-hidden transition-all duration-300"> | |
| <!-- Card Header --> | |
| <div class="preview-header flex items-start justify-between mb-8"> | |
| <div class="flex items-center gap-4"> | |
| <div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-3xl shadow-lg shadow-indigo-200/50"> | |
| {{ project.logoEmoji }} | |
| </div> | |
| <div> | |
| <h2 class="text-2xl font-bold tracking-tight mb-1" :class="theme === 'dark' ? 'text-white' : 'text-slate-900'">{{ project.name }}</h2> | |
| <div class="flex items-center gap-3"> | |
| <span class="font-mono text-sm px-2 py-0.5 rounded bg-slate-100 text-slate-600 border border-slate-200" | |
| :class="theme === 'dark' ? 'bg-slate-800 border-slate-700 text-slate-300' : ''"> | |
| {{ project.version }} | |
| </span> | |
| <span class="text-sm text-slate-400">{{ project.date }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Card Content --> | |
| <div class="space-y-8 flex-1"> | |
| <div v-for="(section, index) in sections" :key="index"> | |
| <h4 class="text-sm font-bold uppercase tracking-wider mb-3 flex items-center gap-2" | |
| :class="{ | |
| 'text-blue-600': section.type === 'feature' && theme !== 'dark', | |
| 'text-blue-400': section.type === 'feature' && theme === 'dark', | |
| 'text-red-600': section.type === 'fix' && theme !== 'dark', | |
| 'text-red-400': section.type === 'fix' && theme === 'dark', | |
| 'text-green-600': section.type === 'improve' && theme !== 'dark', | |
| 'text-green-400': section.type === 'improve' && theme === 'dark', | |
| 'text-slate-900': theme === 'minimal' | |
| }"> | |
| <span>{{ section.title }}</span> | |
| <span class="h-px flex-1 bg-slate-100" :class="theme === 'dark' ? 'bg-slate-800' : (theme === 'minimal' ? 'bg-black' : '')"></span> | |
| </h4> | |
| <ul class="space-y-3"> | |
| <li v-for="(item, i) in section.items" :key="i" class="flex items-start gap-3 text-sm leading-relaxed" :class="theme === 'dark' ? 'text-slate-300' : 'text-slate-600'"> | |
| <span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" | |
| :class="{ | |
| 'bg-blue-500': section.type === 'feature', | |
| 'bg-red-500': section.type === 'fix', | |
| 'bg-green-500': section.type === 'improve', | |
| 'bg-black': theme === 'minimal' | |
| }"></span> | |
| <span>{{ item.text }}</span> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| <!-- Card Footer --> | |
| <div class="mt-10 pt-6 border-t border-slate-100 flex items-center justify-between" :class="theme === 'dark' ? 'border-slate-800' : (theme === 'minimal' ? 'border-black' : '')"> | |
| <div class="text-xs text-slate-400 font-medium">Generated by Changelog Studio</div> | |
| <div class="flex gap-2"> | |
| <div class="w-2 h-2 rounded-full bg-slate-200"></div> | |
| <div class="w-2 h-2 rounded-full bg-slate-200"></div> | |
| <div class="w-2 h-2 rounded-full bg-slate-200"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast Notification --> | |
| <div v-if="toast.show" class="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-full shadow-xl flex items-center gap-3 z-50 transition-all duration-300" :class="toast.show ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0'"> | |
| <i class="fa-solid fa-circle-check text-green-400"></i> | |
| <span class="text-sm font-medium">{{ toast.message }}</span> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, watch } = Vue; | |
| createApp({ | |
| setup() { | |
| const toast = ref({ show: false, message: '' }); | |
| const captureArea = ref(null); | |
| const project = ref({ | |
| name: "我的超棒产品", | |
| version: "v1.0.0", | |
| date: new Date().toISOString().split('T')[0], | |
| logoEmoji: "🚀" | |
| }); | |
| const theme = ref('modern'); | |
| const sections = ref([ | |
| { | |
| type: 'feature', | |
| title: '✨ 新功能', | |
| items: [ | |
| { text: '新增暗黑模式支持,提供更好的夜间使用体验' }, | |
| { text: '引入新的 API 端点用于用户管理' } | |
| ] | |
| }, | |
| { | |
| type: 'fix', | |
| title: '🐛 修复', | |
| items: [ | |
| { text: '修复移动设备上的布局偏移问题' }, | |
| { text: '解决登录会话超时问题' } | |
| ] | |
| }, | |
| { | |
| type: 'improve', | |
| title: '🚀 优化', | |
| items: [ | |
| { text: '优化数据库查询,加载速度提升 2 倍' } | |
| ] | |
| } | |
| ]); | |
| // Load from local storage | |
| onMounted(() => { | |
| const saved = localStorage.getItem('changelog-studio-data'); | |
| if (saved) { | |
| const data = JSON.parse(saved); | |
| project.value = data.project || project.value; | |
| theme.value = data.theme || theme.value; | |
| sections.value = data.sections || sections.value; | |
| } | |
| }); | |
| // Save to local storage | |
| watch([project, theme, sections], () => { | |
| localStorage.setItem('changelog-studio-data', JSON.stringify({ | |
| project: project.value, | |
| theme: theme.value, | |
| sections: sections.value | |
| })); | |
| }, { deep: true }); | |
| const showToast = (msg) => { | |
| toast.value = { show: true, message: msg }; | |
| setTimeout(() => toast.value.show = false, 3000); | |
| }; | |
| const addSection = (type) => { | |
| const titles = { feature: '✨ 新功能', fix: '🐛 修复', improve: '🚀 优化' }; | |
| sections.value.push({ | |
| type, | |
| title: titles[type] || '新小节', | |
| items: [{ text: '' }] | |
| }); | |
| }; | |
| const removeSection = (index) => { | |
| sections.value.splice(index, 1); | |
| }; | |
| const addItem = (sectionIndex) => { | |
| sections.value[sectionIndex].items.push({ text: '' }); | |
| }; | |
| const removeItem = (sectionIndex, itemIndex) => { | |
| sections.value[sectionIndex].items.splice(itemIndex, 1); | |
| }; | |
| const copyMarkdown = () => { | |
| let md = `# ${project.value.name} ${project.value.version}\n\n`; | |
| md += `> Released on ${project.value.date}\n\n`; | |
| sections.value.forEach(section => { | |
| md += `### ${section.title}\n`; | |
| section.items.forEach(item => { | |
| if(item.text) md += `- ${item.text}\n`; | |
| }); | |
| md += `\n`; | |
| }); | |
| navigator.clipboard.writeText(md).then(() => { | |
| showToast('Markdown 已复制到剪贴板!'); | |
| }); | |
| }; | |
| const downloadImage = async () => { | |
| if (!captureArea.value) return; | |
| // Temporarily remove shadow and transform for clean capture if needed, | |
| // but html2canvas usually handles it. | |
| // We might need to scale up for better resolution. | |
| try { | |
| const canvas = await html2canvas(captureArea.value, { | |
| scale: 2, // Retina quality | |
| backgroundColor: null, | |
| useCORS: true | |
| }); | |
| const link = document.createElement('a'); | |
| link.download = `${project.value.name}-changelog-${project.value.version}.png`; | |
| link.href = canvas.toDataURL('image/png'); | |
| link.click(); | |
| showToast('图片下载成功!'); | |
| } catch (err) { | |
| console.error(err); | |
| showToast('图片生成失败,请重试'); | |
| } | |
| }; | |
| return { | |
| project, | |
| theme, | |
| sections, | |
| captureArea, | |
| toast, | |
| addSection, | |
| removeSection, | |
| addItem, | |
| removeItem, | |
| copyMarkdown, | |
| downloadImage | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |