changelog-studio / static /index.html
duqing2026's picture
Optimize app structure, fix Jinja2 conflict, use local assets, and localize to Chinese
715ec78
<!DOCTYPE html>
<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>