duqing2026's picture
Fix Jinja2 error, add auto-save & JSON export, optimize Dockerfile
a30d178
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>交互式清单专家 (Interactive Checklist Pro)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Phosphor Icons -->
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
</style>
</head>
<body class="bg-gray-50 text-gray-800 h-screen flex flex-col overflow-hidden">
<div id="app" class="flex-1 flex flex-col h-full">
{% raw %}
<!-- Header -->
<header class="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 shrink-0 z-10">
<div class="flex items-center gap-3">
<div class="bg-indigo-600 text-white p-2 rounded-lg">
<i class="ph ph-check-square text-xl"></i>
</div>
<h1 class="text-xl font-bold text-gray-900 tracking-tight">Interactive Checklist Pro</h1>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 mr-2 border-r border-gray-200 pr-3">
<button @click="importJSON" class="text-gray-500 hover:text-indigo-600 p-2 rounded-lg transition" title="导入配置 (Import JSON)">
<i class="ph ph-upload-simple text-lg"></i>
</button>
<button @click="exportJSON" class="text-gray-500 hover:text-indigo-600 p-2 rounded-lg transition" title="备份配置 (Export JSON)">
<i class="ph ph-floppy-disk text-lg"></i>
</button>
<button @click="clearStorage" class="text-gray-400 hover:text-red-600 p-2 rounded-lg transition" title="重置 (Reset)">
<i class="ph ph-arrow-counter-clockwise text-lg"></i>
</button>
</div>
<button @click="downloadHTML" :disabled="isExporting" class="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-5 py-2 rounded-lg font-medium transition-colors shadow-sm disabled:opacity-50">
<i class="ph ph-download-simple" v-if="!isExporting"></i>
<i class="ph ph-spinner animate-spin" v-else></i>
<span v-text="isExporting ? '生成中...' : '导出单文件 HTML'"></span>
</button>
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left: Editor -->
<div class="w-1/2 bg-white border-r border-gray-200 flex flex-col overflow-hidden">
<div class="p-6 overflow-y-auto custom-scrollbar flex-1 pb-20">
<!-- Global Settings -->
<section class="mb-8">
<h2 class="text-sm uppercase tracking-wider text-gray-500 font-semibold mb-4">基本信息</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">清单标题</label>
<input v-model="checklist.title" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition" placeholder="例如:SEO 终极检查表">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">描述 / 引言</label>
<textarea v-model="checklist.description" rows="3" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition" placeholder="简要介绍这个清单的用途..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">作者 / 品牌</label>
<input v-model="checklist.author" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition" placeholder="例如:YourName @ Twitter">
</div>
<!-- Theme Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">主题色</label>
<div class="flex gap-3">
<button v-for="color in themeColors" :key="color.value"
@click="checklist.theme = color.value"
class="w-8 h-8 rounded-full border-2 transition-all"
:class="[color.bgClass, checklist.theme === color.value ? 'border-gray-900 scale-110' : 'border-transparent opacity-70 hover:opacity-100']">
</button>
</div>
</div>
</div>
</section>
<!-- Content Editor -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm uppercase tracking-wider text-gray-500 font-semibold">清单内容</h2>
<button @click="addGroup" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1">
<i class="ph ph-plus"></i> 添加分组
</button>
</div>
<div class="space-y-6">
<div v-for="(group, gIndex) in checklist.groups" :key="gIndex" class="border border-gray-200 rounded-xl bg-gray-50 overflow-hidden">
<!-- Group Header -->
<div class="bg-gray-100 p-4 border-b border-gray-200 flex items-start gap-3">
<div class="flex flex-col gap-1 pt-1">
<button @click="moveGroup(gIndex, -1)" :disabled="gIndex === 0" class="text-gray-400 hover:text-gray-700 disabled:opacity-30"><i class="ph ph-caret-up"></i></button>
<button @click="moveGroup(gIndex, 1)" :disabled="gIndex === checklist.groups.length - 1" class="text-gray-400 hover:text-gray-700 disabled:opacity-30"><i class="ph ph-caret-down"></i></button>
</div>
<div class="flex-1">
<input v-model="group.title" type="text" class="w-full bg-transparent border-b border-transparent hover:border-gray-300 focus:border-indigo-500 focus:ring-0 px-0 py-1 font-semibold text-gray-800 placeholder-gray-400 transition" placeholder="分组名称 (例如:准备工作)">
</div>
<button @click="removeGroup(gIndex)" class="text-red-400 hover:text-red-600 p-1"><i class="ph ph-trash"></i></button>
</div>
<!-- Items List -->
<div class="p-4 space-y-3">
<div v-for="(item, iIndex) in group.items" :key="iIndex" class="flex items-start gap-3 bg-white p-3 rounded-lg border border-gray-100 shadow-sm group">
<div class="flex flex-col gap-0.5 pt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="moveItem(gIndex, iIndex, -1)" :disabled="iIndex === 0" class="text-gray-300 hover:text-gray-600 text-xs"><i class="ph ph-caret-up"></i></button>
<button @click="moveItem(gIndex, iIndex, 1)" :disabled="iIndex === group.items.length - 1" class="text-gray-300 hover:text-gray-600 text-xs"><i class="ph ph-caret-down"></i></button>
</div>
<div class="flex-1 space-y-2">
<input v-model="item.text" type="text" class="w-full border-b border-gray-100 focus:border-indigo-300 focus:ring-0 px-0 py-0.5 text-sm text-gray-800 placeholder-gray-400" placeholder="检查项内容...">
<!-- Optional Fields Toggle -->
<div class="flex gap-4 text-xs">
<input v-model="item.note" type="text" class="flex-1 bg-gray-50 rounded px-2 py-1 text-gray-600 placeholder-gray-300 border-none" placeholder="备注/提示 (可选)">
<input v-model="item.link" type="text" class="flex-1 bg-gray-50 rounded px-2 py-1 text-gray-600 placeholder-gray-300 border-none" placeholder="链接 URL (可选)">
</div>
</div>
<button @click="removeItem(gIndex, iIndex)" class="text-gray-300 hover:text-red-500 pt-1 opacity-0 group-hover:opacity-100 transition-opacity"><i class="ph ph-x"></i></button>
</div>
<button @click="addItem(gIndex)" class="w-full py-2 border border-dashed border-gray-300 rounded-lg text-gray-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition flex items-center justify-center gap-2">
<i class="ph ph-plus-circle"></i> 添加检查项
</button>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Right: Preview -->
<div class="w-1/2 bg-gray-100 flex flex-col overflow-hidden relative">
<div class="absolute top-4 right-4 bg-white/80 backdrop-blur px-3 py-1 rounded-full text-xs font-medium text-gray-500 border border-gray-200 shadow-sm z-10">
实时预览 (Export Preview)
</div>
<div class="flex-1 overflow-y-auto p-8 flex justify-center">
<!-- Phone/Tablet Frame -->
<div class="w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden border-8 border-gray-800 flex flex-col min-h-[600px] max-h-[90vh]">
<!-- App Header -->
<div class="p-6 text-white transition-colors duration-300 shrink-0" :class="currentThemeClass">
<div class="text-xs opacity-80 mb-1 uppercase tracking-wider">{{ checklist.author || 'Author Name' }}</div>
<h2 class="text-2xl font-bold leading-tight">{{ checklist.title || 'Checklist Title' }}</h2>
<p class="text-sm opacity-90 mt-2 line-clamp-2" v-if="checklist.description">{{ checklist.description }}</p>
<!-- Progress Bar Preview -->
<div class="mt-6">
<div class="flex justify-between text-xs font-medium mb-1 opacity-90">
<span>进度 0%</span>
<span>0/{{ totalItems }}</span>
</div>
<div class="h-2 bg-black/20 rounded-full overflow-hidden">
<div class="h-full bg-white/90 w-0"></div>
</div>
</div>
</div>
<!-- App Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6 bg-white">
<div v-for="(group, gIndex) in checklist.groups" :key="gIndex">
<h3 class="font-bold text-gray-800 mb-3 flex items-center gap-2">
<span class="w-1.5 h-4 rounded-full" :class="currentThemeTextClass.replace('text-', 'bg-')"></span>
{{ group.title || 'Untitled Group' }}
</h3>
<div class="space-y-2">
<div v-for="(item, iIndex) in group.items" :key="iIndex" class="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-gray-200 transition-colors cursor-pointer">
<div class="w-5 h-5 rounded border-2 border-gray-300 shrink-0 mt-0.5"></div>
<div class="flex-1">
<div class="text-gray-700 text-sm font-medium">{{ item.text || 'Item text' }}</div>
<div v-if="item.note" class="text-xs text-gray-500 mt-1">{{ item.note }}</div>
<div v-if="item.link" class="text-xs mt-1 text-blue-500 underline truncate">Resource Link</div>
</div>
</div>
</div>
</div>
<div v-if="checklist.groups.length === 0" class="text-center text-gray-400 py-10 text-sm">
暂无内容,请在左侧添加
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 p-3 text-center text-xs text-gray-400 border-t border-gray-100 shrink-0">
Powered by Interactive Checklist Pro
</div>
</div>
</div>
</div>
</div>
</div>
{% endraw %}
<script>
const { createApp, ref, computed, watch, onMounted } = Vue;
createApp({
setup() {
const checklist = ref({
title: '产品发布检查清单',
description: '确保您的产品发布顺利无误的终极指南。涵盖从准备到发布后的关键步骤。',
author: 'Product Team',
theme: 'indigo',
groups: [
{
title: '前期准备',
items: [
{ text: '确定目标受众', note: '分析用户画像和痛点', link: '' },
{ text: '完成核心功能测试', note: '确保无重大 Bug', link: '' },
{ text: '准备营销素材', note: '包括 Banner、文案、演示视频', link: '' }
]
},
{
title: '发布日',
items: [
{ text: '部署上线', note: '执行部署脚本,检查服务状态', link: '' },
{ text: '发布公告', note: '社交媒体、邮件列表、博客', link: '' },
{ text: '监控系统', note: '关注服务器负载和错误日志', link: '' }
]
}
]
});
// Auto-save logic
onMounted(() => {
const saved = localStorage.getItem('checklist_pro_draft');
if (saved) {
try {
checklist.value = JSON.parse(saved);
} catch (e) {
console.error('Failed to load draft', e);
}
}
});
watch(checklist, (newVal) => {
localStorage.setItem('checklist_pro_draft', JSON.stringify(newVal));
}, { deep: true });
const isExporting = ref(false);
const themeColors = [
{ value: 'indigo', bgClass: 'bg-indigo-600' },
{ value: 'blue', bgClass: 'bg-blue-600' },
{ value: 'emerald', bgClass: 'bg-emerald-600' },
{ value: 'rose', bgClass: 'bg-rose-600' },
{ value: 'amber', bgClass: 'bg-amber-600' },
{ value: 'slate', bgClass: 'bg-slate-800' },
];
const currentThemeClass = computed(() => {
const map = {
'indigo': 'bg-indigo-600',
'blue': 'bg-blue-600',
'emerald': 'bg-emerald-600',
'rose': 'bg-rose-600',
'amber': 'bg-amber-600',
'slate': 'bg-slate-800',
};
return map[checklist.value.theme] || 'bg-indigo-600';
});
const currentThemeTextClass = computed(() => {
const map = {
'indigo': 'text-indigo-600',
'blue': 'text-blue-600',
'emerald': 'text-emerald-600',
'rose': 'text-rose-600',
'amber': 'text-amber-600',
'slate': 'text-slate-800',
};
return map[checklist.value.theme] || 'text-indigo-600';
});
const totalItems = computed(() => {
return checklist.value.groups.reduce((acc, g) => acc + g.items.length, 0);
});
// Actions
const addGroup = () => {
checklist.value.groups.push({ title: '', items: [] });
};
const removeGroup = (index) => {
if(confirm('确定删除此分组吗?')) checklist.value.groups.splice(index, 1);
};
const moveGroup = (index, direction) => {
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < checklist.value.groups.length) {
const temp = checklist.value.groups[index];
checklist.value.groups[index] = checklist.value.groups[newIndex];
checklist.value.groups[newIndex] = temp;
}
};
const addItem = (gIndex) => {
checklist.value.groups[gIndex].items.push({ text: '', note: '', link: '' });
};
const removeItem = (gIndex, iIndex) => {
checklist.value.groups[gIndex].items.splice(iIndex, 1);
};
const moveItem = (gIndex, iIndex, direction) => {
const items = checklist.value.groups[gIndex].items;
const newIndex = iIndex + direction;
if (newIndex >= 0 && newIndex < items.length) {
const temp = items[iIndex];
items[iIndex] = items[newIndex];
items[newIndex] = temp;
}
};
const downloadHTML = async () => {
isExporting.value = true;
try {
const response = await fetch('/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(checklist.value)
});
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `checklist-${Date.now()}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (e) {
alert('导出失败: ' + e.message);
} finally {
isExporting.value = false;
}
};
return {
checklist,
themeColors,
currentThemeClass,
currentThemeTextClass,
totalItems,
addGroup, removeGroup, moveGroup,
addItem, removeItem, moveItem,
downloadHTML, isExporting
};
}
}).mount('#app');
</script>
</body>
</html>
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = readerEvent => {
try {
const content = readerEvent.target.result;
checklist.value = JSON.parse(content);
} catch (e) {
alert('Invalid JSON file');
}
}
}
input.click();
};
const clearStorage = () => {
if(confirm('确定要清空本地缓存并重置为默认值吗?此操作无法撤销。')) {
localStorage.removeItem('checklist_pro_draft');
location.reload();
}
};
return {
checklist,
themeColors,
currentThemeClass,
currentThemeTextClass,
totalItems,
addGroup, removeGroup, moveGroup,
addItem, removeItem, moveItem,
downloadHTML, isExporting,
exportJSON, importJSON, clearStorage
};
}
}).mount('#app');
</script>
</body>
</html>