| <!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 Maker)</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |
| <style> |
| ::-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; } |
| [v-cloak] { display: none; } |
| .preview-transition { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } |
| </style> |
| </head> |
| <body class="bg-gray-100 h-screen flex flex-col overflow-hidden text-gray-800 font-sans"> |
| <div id="app" class="flex-1 flex flex-col h-full" v-cloak> |
| |
| <header class="bg-white border-b border-gray-200 px-4 md:px-6 py-3 flex justify-between items-center shadow-sm z-20"> |
| <div class="flex items-center gap-3"> |
| <div class="bg-gradient-to-br from-indigo-600 to-purple-600 text-white p-2.5 rounded-lg shadow-md transform hover:scale-105 transition duration-200"> |
| <i class="fa-solid fa-list-check text-xl"></i> |
| </div> |
| <div> |
| <h1 class="text-lg md:text-xl font-bold text-gray-800 tracking-tight">交互式清单生成器</h1> |
| <p class="text-xs text-gray-500 font-medium">Interactive Checklist Maker</p> |
| </div> |
| </div> |
| |
| <div class="flex gap-2"> |
| |
| <div class="flex bg-gray-100 p-1 rounded-lg mr-2"> |
| <button @click="triggerImport" class="px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-indigo-600 hover:bg-white rounded-md transition shadow-sm hover:shadow flex items-center" title="导入 JSON 配置"> |
| <i class="fa-solid fa-file-import mr-1.5"></i>导入 |
| </button> |
| <button @click="exportJSON" class="px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-indigo-600 hover:bg-white rounded-md transition shadow-sm hover:shadow flex items-center" title="导出 JSON 配置"> |
| <i class="fa-solid fa-file-export mr-1.5"></i>备份 |
| </button> |
| </div> |
| <input type="file" ref="fileInput" @change="handleImport" class="hidden" accept=".json"> |
|
|
| <button @click="resetData" class="px-3 py-1.5 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition text-sm font-medium border border-transparent hover:border-red-100"> |
| <i class="fa-solid fa-trash-can mr-1.5"></i>清空 |
| </button> |
| <button @click="exportHTML" class="px-4 md:px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg shadow-lg shadow-indigo-200 transition flex items-center transform active:scale-95 text-sm md:text-base"> |
| <i class="fa-solid fa-download mr-2"></i>导出 HTML |
| </button> |
| </div> |
| </header> |
|
|
| |
| <div class="flex-1 flex overflow-hidden"> |
| |
| <div class="w-1/3 min-w-[360px] max-w-[480px] bg-white border-r border-gray-200 overflow-y-auto p-4 md:p-6 flex flex-col gap-6 custom-scrollbar z-10"> |
| |
| |
| <div class="bg-indigo-50 p-4 rounded-xl border border-indigo-100"> |
| <label class="block text-xs font-bold text-indigo-600 uppercase tracking-wider mb-2 flex items-center"> |
| <i class="fa-solid fa-wand-magic-sparkles mr-1.5"></i>快速开始 / 模板 |
| </label> |
| <select v-model="selectedTemplate" @change="applyTemplate" class="w-full px-3 py-2 bg-white border border-indigo-200 rounded-lg text-sm text-gray-700 focus:ring-2 focus:ring-indigo-500 outline-none cursor-pointer"> |
| <option value="" disabled selected>选择一个预设模板...</option> |
| <option v-for="(tpl, key) in templates" :key="key" :value="key">{{ tpl.name }}</option> |
| </select> |
| </div> |
|
|
| |
| <div class="space-y-4"> |
| <div class="flex items-center justify-between pb-2 border-b border-gray-100"> |
| <h3 class="font-bold text-gray-700 flex items-center text-sm"> |
| <i class="fa-solid fa-sliders mr-2 text-gray-400"></i>基本信息 |
| </h3> |
| </div> |
| |
| <div class="space-y-3"> |
| <div> |
| <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">清单标题</label> |
| <input v-model="checklist.title" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition text-sm"> |
| </div> |
| <div> |
| <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">描述/副标题</label> |
| <textarea v-model="checklist.description" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition resize-none text-sm"></textarea> |
| </div> |
| <div> |
| <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">主题色系</label> |
| <div class="flex gap-2 flex-wrap"> |
| <button v-for="color in themeColors" :key="color.value" |
| @click="checklist.theme = color.value" |
| :class="['w-6 h-6 rounded-full shadow-sm transition transform hover:scale-110 flex items-center justify-center ring-2 ring-offset-1', checklist.theme === color.value ? 'ring-gray-400 scale-110' : 'ring-transparent']" |
| :style="{ backgroundColor: color.hex }" |
| :title="color.label"> |
| <i v-if="checklist.theme === color.value" class="fa-solid fa-check text-white text-[10px]"></i> |
| </button> |
| </div> |
| </div> |
| <div> |
| <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">页脚版权</label> |
| <input v-model="checklist.author" type="text" placeholder="例如: © 2024 Your Name" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition text-sm"> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 pb-10"> |
| <div class="flex justify-between items-center mb-4 sticky top-0 bg-white/95 backdrop-blur py-3 z-10 border-b border-gray-100"> |
| <h3 class="font-bold text-gray-700 flex items-center text-sm"> |
| <i class="fa-solid fa-layer-group mr-2 text-gray-400"></i>内容章节 |
| </h3> |
| <button @click="addSection" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-full transition flex items-center"> |
| <i class="fa-solid fa-plus mr-1"></i>添加章节 |
| </button> |
| </div> |
|
|
| <div class="space-y-5"> |
| <div v-for="(section, sIndex) in checklist.sections" :key="sIndex" class="border border-gray-200 rounded-xl overflow-hidden bg-white shadow-sm hover:shadow-md transition group/section"> |
| |
| <div class="bg-gray-50 p-3 flex justify-between items-center border-b border-gray-100"> |
| <div class="flex items-center flex-1 gap-2"> |
| <span class="text-xs font-bold text-white bg-gray-400 rounded px-1.5 py-0.5">#{{ sIndex + 1 }}</span> |
| <input v-model="section.title" placeholder="输入章节标题..." class="bg-transparent font-bold text-gray-700 outline-none w-full placeholder-gray-400 text-sm"> |
| </div> |
| <button @click="removeSection(sIndex)" class="text-gray-400 hover:text-red-500 p-1.5 rounded hover:bg-red-50 transition opacity-0 group-hover/section:opacity-100" title="删除章节"> |
| <i class="fa-solid fa-trash-can"></i> |
| </button> |
| </div> |
| |
| <div class="p-3 space-y-2 bg-white"> |
| <div v-for="(task, tIndex) in section.tasks" :key="tIndex" class="flex gap-2 items-start group/task p-1.5 hover:bg-gray-50 rounded-lg transition border border-transparent hover:border-gray-100 relative"> |
| <div class="mt-2 w-1.5 h-1.5 rounded-full bg-gray-300 flex-shrink-0 group-hover/task:bg-indigo-400 transition-colors"></div> |
| <div class="flex-1 space-y-1 min-w-0"> |
| <input v-model="task.text" placeholder="输入任务内容" class="w-full text-sm font-medium text-gray-700 bg-transparent border-none p-0 focus:ring-0 placeholder-gray-300"> |
| <input v-model="task.note" placeholder="添加备注 (可选)" class="w-full text-xs text-gray-400 bg-transparent border-none p-0 focus:ring-0 placeholder-gray-200"> |
| </div> |
| <button @click="removeTask(sIndex, tIndex)" class="absolute right-1 top-1 text-gray-300 hover:text-red-400 opacity-0 group-hover/task:opacity-100 transition px-1"> |
| <i class="fa-solid fa-times"></i> |
| </button> |
| </div> |
| <button @click="addTask(sIndex)" class="w-full py-2 mt-2 text-xs font-medium text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 border border-dashed border-gray-200 hover:border-indigo-200 rounded-lg transition flex items-center justify-center"> |
| <i class="fa-solid fa-plus mr-1"></i>添加任务 |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| <div v-if="checklist.sections.length === 0" class="text-center py-12 text-gray-400 bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 mt-4"> |
| <i class="fa-regular fa-clipboard text-4xl mb-3 opacity-30"></i> |
| <p class="text-sm">暂无内容,请添加章节</p> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 bg-gray-100 flex flex-col min-w-0 relative"> |
| |
| <div class="absolute top-4 left-1/2 transform -translate-x-1/2 z-20 bg-white/90 backdrop-blur shadow-lg rounded-full p-1.5 flex gap-1 border border-gray-200"> |
| <button @click="previewMode = 'mobile'" :class="['w-8 h-8 rounded-full flex items-center justify-center transition text-sm', previewMode === 'mobile' ? 'bg-indigo-600 text-white' : 'text-gray-500 hover:bg-gray-100']" title="手机视图"> |
| <i class="fa-solid fa-mobile-screen"></i> |
| </button> |
| <button @click="previewMode = 'tablet'" :class="['w-8 h-8 rounded-full flex items-center justify-center transition text-sm', previewMode === 'tablet' ? 'bg-indigo-600 text-white' : 'text-gray-500 hover:bg-gray-100']" title="平板视图"> |
| <i class="fa-solid fa-tablet-screen-button"></i> |
| </button> |
| <button @click="previewMode = 'desktop'" :class="['w-8 h-8 rounded-full flex items-center justify-center transition text-sm', previewMode === 'desktop' ? 'bg-indigo-600 text-white' : 'text-gray-500 hover:bg-gray-100']" title="桌面视图"> |
| <i class="fa-solid fa-desktop"></i> |
| </button> |
| </div> |
|
|
| <div class="flex-1 overflow-y-auto p-4 md:p-8 flex justify-center custom-scrollbar"> |
| <div :class="['bg-white shadow-2xl overflow-hidden flex flex-col relative transition-all duration-300 ring-1 ring-black/5 preview-transition origin-top', |
| previewMode === 'mobile' ? 'w-[375px] rounded-[30px] my-4 min-h-[667px]' : |
| previewMode === 'tablet' ? 'w-[768px] rounded-[24px] my-4 min-h-[1024px]' : |
| 'w-full max-w-3xl rounded-[16px] min-h-[800px]']"> |
| |
| |
| <div :class="`bg-${checklist.theme}-600`" class="p-8 md:p-10 text-white relative overflow-hidden shrink-0"> |
| <div class="absolute top-0 right-0 p-32 bg-white/5 rounded-full blur-3xl transform translate-x-10 -translate-y-10"></div> |
| <div class="relative z-10"> |
| <h1 class="font-bold mb-3 tracking-tight break-words" :class="previewMode === 'mobile' ? 'text-2xl' : 'text-3xl'">{{ checklist.title || '我的清单' }}</h1> |
| <p class="opacity-90 font-light leading-relaxed break-words whitespace-pre-line" :class="previewMode === 'mobile' ? 'text-sm' : 'text-lg'">{{ checklist.description || '这里是清单的描述信息...' }}</p> |
| |
| |
| <div class="mt-8"> |
| <div class="flex justify-between text-xs font-bold uppercase tracking-widest opacity-80 mb-2"> |
| <span>进度预览</span> |
| <span>0%</span> |
| </div> |
| <div class="bg-black/20 rounded-full h-2.5 overflow-hidden backdrop-blur-sm"> |
| <div class="h-full bg-white/90 w-0"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="p-6 md:p-10 space-y-8 flex-1 bg-white"> |
| <div v-for="(section, index) in checklist.sections" :key="index"> |
| <h2 class="font-bold text-gray-800 mb-4 pb-2 border-b border-gray-100 flex items-center" :class="previewMode === 'mobile' ? 'text-lg' : 'text-xl'"> |
| <span :class="`bg-${checklist.theme}-100 text-${checklist.theme}-600`" class="w-7 h-7 rounded-lg flex items-center justify-center text-sm mr-3 font-bold shadow-sm shrink-0"> |
| {{ index + 1 }} |
| </span> |
| <span class="break-words">{{ section.title || '未命名章节' }}</span> |
| </h2> |
| <div class="space-y-3"> |
| <div v-for="(task, tIndex) in section.tasks" :key="tIndex" |
| class="flex items-start p-3 md:p-4 rounded-xl border border-gray-100 hover:border-gray-200 hover:bg-gray-50 transition cursor-pointer group"> |
| <div :class="`text-${checklist.theme}-600`" class="mt-0.5 mr-3 md:mr-4 text-xl opacity-40 group-hover:opacity-100 transition shrink-0"> |
| <i class="far fa-square"></i> |
| </div> |
| <div class="min-w-0"> |
| <div class="text-gray-700 font-medium leading-snug break-words" :class="previewMode === 'mobile' ? 'text-base' : 'text-lg'">{{ task.text || '任务内容' }}</div> |
| <div v-if="task.note" class="text-sm text-gray-500 mt-1 leading-relaxed break-words">{{ task.note }}</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div v-if="checklist.sections.length === 0" class="text-center py-20 opacity-30 select-none"> |
| <div class="text-6xl mb-4">✨</div> |
| <p>预览区域:添加内容后在此显示</p> |
| </div> |
| </div> |
|
|
| |
| <div class="bg-gray-50 p-6 md:p-8 text-center border-t border-gray-100 shrink-0"> |
| <p class="text-gray-400 text-xs md:text-sm font-medium">{{ checklist.author || '© 2024 Interactive Checklist' }}</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const { createApp, ref, computed, onMounted } = Vue; |
| |
| // 预设模板数据 |
| const TEMPLATES = { |
| product_launch: { |
| name: "🚀 产品发布检查清单", |
| data: { |
| title: '产品发布检查清单', |
| description: '确保您的产品发布万无一失。从准备物料到最终上线,每一步都至关重要。', |
| theme: 'indigo', |
| author: '© 2024 Product Team', |
| sections: [ |
| { |
| title: '准备阶段', |
| tasks: [ |
| { text: '确定发布日期', note: '建议避开重大节假日和周末' }, |
| { text: '准备宣传物料', note: '包括社交媒体图片、文案、演示视频等' }, |
| { text: '检查服务器配置', note: '确保能够承受预期的流量高峰' } |
| ] |
| }, |
| { |
| title: '上线检查', |
| tasks: [ |
| { text: '全功能测试', note: '最后一次回归测试' }, |
| { text: '更新文档', note: '确保帮助文档与最新版本一致' }, |
| { text: '配置监控报警', note: 'Sentry, UptimeRobot 等' } |
| ] |
| } |
| ] |
| } |
| }, |
| travel_pack: { |
| name: "✈️ 旅行打包清单", |
| data: { |
| title: '旅行打包清单', |
| description: '再也不用担心出门忘带东西了!涵盖证件、衣物、电子产品等全方位检查。', |
| theme: 'emerald', |
| author: '© 2024 Travel Lover', |
| sections: [ |
| { |
| title: '重要证件', |
| tasks: [ |
| { text: '护照/身份证', note: '检查有效期' }, |
| { text: '签证/行程单', note: '建议打印一份纸质备份' }, |
| { text: '酒店预订确认单', note: '' } |
| ] |
| }, |
| { |
| title: '电子产品', |
| tasks: [ |
| { text: '手机及充电器', note: '' }, |
| { text: '转换插头', note: '根据目的地选择' }, |
| { text: '充电宝', note: '不能托运' } |
| ] |
| }, |
| { |
| title: '洗漱用品', |
| tasks: [ |
| { text: '牙刷牙膏', note: '' }, |
| { text: '毛巾', note: '' }, |
| { text: '防晒霜', note: '' } |
| ] |
| } |
| ] |
| } |
| }, |
| frontend_start: { |
| name: "💻 前端项目启动清单", |
| data: { |
| title: '前端项目启动清单', |
| description: '标准化项目初始化流程,确保代码质量和开发规范。', |
| theme: 'cyan', |
| author: '© 2024 Tech Lead', |
| sections: [ |
| { |
| title: '环境搭建', |
| tasks: [ |
| { text: 'Git 初始化', note: 'git init' }, |
| { text: '配置 .gitignore', note: '排除 node_modules, .env 等' }, |
| { text: '锁定 Node 版本', note: '创建 .nvmrc 或 package.json engines' } |
| ] |
| }, |
| { |
| title: '代码规范', |
| tasks: [ |
| { text: '安装 ESLint & Prettier', note: '' }, |
| { text: '配置 Husky & Lint-staged', note: '提交前自动检查' }, |
| { text: '配置 Commitlint', note: '规范提交信息' } |
| ] |
| } |
| ] |
| } |
| }, |
| daily_habit: { |
| name: "☀️ 每日晨间习惯", |
| data: { |
| title: '每日晨间习惯', |
| description: '开启元气满满的一天!', |
| theme: 'amber', |
| author: '© 2024 Better Me', |
| sections: [ |
| { |
| title: '身体唤醒', |
| tasks: [ |
| { text: '喝一杯温水', note: '补充水分' }, |
| { text: '拉伸/瑜伽 10分钟', note: '' }, |
| { text: '冷水洗脸', note: '' } |
| ] |
| }, |
| { |
| title: '心灵充电', |
| tasks: [ |
| { text: '冥想 5 分钟', note: '' }, |
| { text: '阅读 10 页书', note: '' }, |
| { text: '写下今日最重要的3件事', note: 'To-do List' } |
| ] |
| } |
| ] |
| } |
| } |
| }; |
| |
| createApp({ |
| setup() { |
| // 默认使用第一个模板 |
| const checklist = ref(JSON.parse(JSON.stringify(TEMPLATES.product_launch.data))); |
| const templates = TEMPLATES; |
| const selectedTemplate = ref(''); |
| const fileInput = ref(null); |
| const previewMode = ref('desktop'); |
| |
| const themeColors = [ |
| { value: 'indigo', hex: '#4f46e5', label: '靛蓝' }, |
| { value: 'blue', hex: '#2563eb', label: '深蓝' }, |
| { value: 'emerald', hex: '#059669', label: '翡翠' }, |
| { value: 'rose', hex: '#e11d48', label: '玫瑰' }, |
| { value: 'amber', hex: '#d97706', label: '琥珀' }, |
| { value: 'purple', hex: '#7c3aed', label: '紫色' }, |
| { value: 'cyan', hex: '#0891b2', label: '青色' }, |
| { value: 'slate', hex: '#475569', label: '岩灰' }, |
| ]; |
| |
| const totalTasks = computed(() => { |
| return checklist.value.sections.reduce((acc, sec) => acc + sec.tasks.length, 0); |
| }); |
| |
| const addSection = () => { |
| checklist.value.sections.push({ |
| title: '', |
| tasks: [{ text: '', note: '' }] |
| }); |
| }; |
| |
| const removeSection = (index) => { |
| checklist.value.sections.splice(index, 1); |
| }; |
| |
| const addTask = (sIndex) => { |
| checklist.value.sections[sIndex].tasks.push({ text: '', note: '' }); |
| }; |
| |
| const removeTask = (sIndex, tIndex) => { |
| checklist.value.sections[sIndex].tasks.splice(tIndex, 1); |
| }; |
| |
| const resetData = () => { |
| if(confirm('确定要清空当前所有内容吗?')) { |
| checklist.value.sections = []; |
| checklist.value.title = '新建清单'; |
| checklist.value.description = ''; |
| selectedTemplate.value = ''; |
| } |
| }; |
| |
| const applyTemplate = () => { |
| if (selectedTemplate.value && TEMPLATES[selectedTemplate.value]) { |
| if(confirm('应用模板将覆盖当前内容,确定继续吗?')) { |
| checklist.value = JSON.parse(JSON.stringify(TEMPLATES[selectedTemplate.value].data)); |
| } else { |
| selectedTemplate.value = ''; // Revert selection |
| } |
| } |
| }; |
| |
| // JSON Import/Export |
| 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.sections && Array.isArray(data.sections)) { |
| checklist.value = data; |
| alert('配置导入成功!'); |
| } else { |
| alert('无效的配置文件格式'); |
| } |
| } catch (err) { |
| alert('文件解析失败'); |
| console.error(err); |
| } |
| // Reset input |
| event.target.value = ''; |
| }; |
| reader.readAsText(file); |
| }; |
| |
| const exportJSON = () => { |
| const dataStr = JSON.stringify(checklist.value, null, 2); |
| const blob = new Blob([dataStr], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `${checklist.value.title || 'checklist'}_config.json`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }; |
| |
| const exportHTML = () => { |
| const data = JSON.parse(JSON.stringify(checklist.value)); |
| if (!data.title) data.title = "Checklist"; |
| |
| const htmlContent = generateTemplate(data); |
| |
| const blob = new Blob([htmlContent], { type: 'text/html' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `${data.title.replace(/\s+/g, '_')}_checklist.html`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }; |
| |
| return { |
| checklist, |
| themeColors, |
| totalTasks, |
| addSection, |
| removeSection, |
| addTask, |
| removeTask, |
| resetData, |
| exportHTML, |
| templates, |
| selectedTemplate, |
| applyTemplate, |
| fileInput, |
| triggerImport, |
| handleImport, |
| exportJSON, |
| previewMode |
| }; |
| } |
| }).mount('#app'); |
| |
| function generateTemplate(data) { |
| // NOTE: We must escape the closing script tag as <\/script> |
| return `<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>${data.title}</title> |
| <script src="https://cdn.tailwindcss.com"><\/script> |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"><\/script> |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |
| <style> |
| [v-cloak] { display: none; } |
| .slide-enter-active, .slide-leave-active { transition: all 0.3s ease; } |
| .slide-enter-from, .slide-leave-to { transform: translateY(-10px); opacity: 0; } |
| ::-webkit-scrollbar { width: 6px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } |
| .confetti { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999; } |
| </style> |
| </head> |
| <body class="bg-gray-50 min-h-screen text-gray-800 font-sans selection:bg-${data.theme}-100 selection:text-${data.theme}-800"> |
| <div id="app" class="max-w-3xl mx-auto min-h-screen bg-white shadow-2xl flex flex-col relative" v-cloak> |
| |
| |
| <header class="bg-${data.theme}-600 text-white p-8 sticky top-0 z-30 shadow-lg transition-all duration-300" :class="{'py-4 px-6': scrolled, 'p-8 md:p-12': !scrolled}"> |
| <div class="flex justify-between items-start"> |
| <div class="flex-1 pr-4"> |
| <h1 class="font-bold transition-all duration-300 leading-tight" :class="scrolled ? 'text-xl' : 'text-3xl md:text-4xl mb-3'">${data.title}</h1> |
| <p class="opacity-90 text-sm md:text-base font-light leading-relaxed transition-all duration-300 whitespace-pre-line" v-show="!scrolled" :style="{ maxHeight: scrolled ? '0px' : '200px', opacity: scrolled ? 0 : 0.9 }">${data.description}</p> |
| </div> |
| <div class="text-right flex-shrink-0 bg-white/10 p-2 rounded-lg backdrop-blur-sm border border-white/10"> |
| <div class="text-2xl font-bold">{{ progress }}%</div> |
| <div class="text-xs opacity-80 font-mono">{{ completedCount }} / {{ totalCount }}</div> |
| </div> |
| </div> |
| |
| <div class="mt-6 bg-black/20 rounded-full h-2.5 overflow-hidden backdrop-blur-sm shadow-inner" :class="{'mt-3 h-1.5': scrolled}"> |
| <div class="h-full bg-white/90 transition-all duration-700 ease-out shadow-sm" :style="{ width: progress + '%' }"></div> |
| </div> |
| </header> |
| |
| |
| <main class="flex-1 p-6 md:p-10 space-y-10"> |
| <div v-for="(section, sIndex) in sections" :key="sIndex" class="animate-fade-in" :style="{ animationDelay: sIndex * 100 + 'ms' }"> |
| <h2 class="text-xl font-bold mb-5 pb-3 border-b border-gray-100 flex items-center text-gray-800 sticky top-[80px] bg-white/95 backdrop-blur z-20 pt-2 -mx-2 px-2"> |
| <span class="w-8 h-8 rounded-lg bg-${data.theme}-100 text-${data.theme}-600 flex items-center justify-center text-sm mr-3 font-bold shadow-sm"> |
| {{ sIndex + 1 }} |
| </span> |
| {{ section.title }} |
| </h2> |
| <div class="space-y-3"> |
| <div v-for="(task, tIndex) in section.tasks" :key="tIndex" |
| @click="toggleTask(sIndex, tIndex)" |
| class="flex items-start p-4 rounded-xl border transition-all duration-200 cursor-pointer select-none group relative overflow-hidden" |
| :class="task.checked ? 'bg-${data.theme}-50 border-${data.theme}-200 shadow-inner' : 'bg-white border-gray-100 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md hover:-translate-y-0.5'"> |
| |
| |
| <div class="mt-1 mr-4 text-2xl transition-all duration-300 transform" |
| :class="task.checked ? 'text-${data.theme}-600 scale-110' : 'text-gray-300 group-hover:text-${data.theme}-400 group-hover:scale-110'"> |
| <i :class="task.checked ? 'fa-solid fa-square-check' : 'fa-regular fa-square'"></i> |
| </div> |
| |
| <div class="flex-1 z-10 min-w-0"> |
| <div class="font-medium text-lg leading-snug transition-all duration-300 break-words" |
| :class="task.checked ? 'text-${data.theme}-800 line-through opacity-60 decoration-2 decoration-${data.theme}-300' : 'text-gray-700'"> |
| {{ task.text }} |
| </div> |
| <div v-if="task.note" class="text-sm mt-1.5 transition-opacity duration-300 leading-relaxed break-words" |
| :class="task.checked ? 'text-${data.theme}-600 opacity-50' : 'text-gray-500'"> |
| {{ task.note }} |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </main> |
| |
| |
| <footer class="p-8 text-center text-gray-400 text-sm border-t border-gray-100 bg-gray-50"> |
| <div class="flex justify-center gap-6 mb-4"> |
| <button @click="resetProgress" class="text-gray-400 hover:text-red-500 hover:underline text-xs flex items-center transition"> |
| <i class="fa-solid fa-rotate-left mr-1"></i>重置进度 |
| </button> |
| </div> |
| <p>${data.author || 'Generated by Interactive Checklist Maker'}</p> |
| </footer> |
| |
| |
| <canvas id="confetti" class="confetti"></canvas> |
| </div> |
| |
| <script> |
| const { createApp, ref, computed, onMounted, watch } = Vue; |
| |
| const STORAGE_KEY = 'checklist_${btoa(unescape(encodeURIComponent(data.title))).replace(/[^a-zA-Z0-9]/g, '')}_v1'; |
| |
| createApp({ |
| setup() { |
| const rawSections = ${JSON.stringify(data.sections)}; |
| |
| const sections = ref(rawSections.map(s => ({ |
| ...s, |
| tasks: s.tasks.map(t => ({ ...t, checked: false })) |
| }))); |
| |
| const scrolled = ref(false); |
| let confettiCtx = null; |
| |
| onMounted(() => { |
| |
| const saved = localStorage.getItem(STORAGE_KEY); |
| if (saved) { |
| try { |
| const savedState = JSON.parse(saved); |
| sections.value.forEach((sec, sIdx) => { |
| if (savedState[sIdx]) { |
| sec.tasks.forEach((task, tIdx) => { |
| if (savedState[sIdx][tIdx] !== undefined) { |
| task.checked = savedState[sIdx][tIdx]; |
| } |
| }); |
| } |
| }); |
| } catch (e) { console.error('Load failed', e); } |
| } |
| |
| window.addEventListener('scroll', () => { |
| scrolled.value = window.scrollY > 50; |
| }); |
| |
| |
| const canvas = document.getElementById('confetti'); |
| canvas.width = window.innerWidth; |
| canvas.height = window.innerHeight; |
| confettiCtx = canvas.getContext('2d'); |
| }); |
| |
| watch(sections, (newVal) => { |
| const state = newVal.map(s => s.tasks.map(t => t.checked)); |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); |
| |
| |
| if (progress.value === 100) { |
| triggerConfetti(); |
| } |
| }, { deep: true }); |
| |
| const totalCount = computed(() => { |
| return sections.value.reduce((acc, s) => acc + s.tasks.length, 0); |
| }); |
| |
| const completedCount = computed(() => { |
| return sections.value.reduce((acc, s) => acc + s.tasks.filter(t => t.checked).length, 0); |
| }); |
| |
| const progress = computed(() => { |
| if (totalCount.value === 0) return 0; |
| return Math.round((completedCount.value / totalCount.value) * 100); |
| }); |
| |
| const toggleTask = (sIdx, tIdx) => { |
| sections.value[sIdx].tasks[tIdx].checked = !sections.value[sIdx].tasks[tIdx].checked; |
| |
| if (navigator.vibrate && sections.value[sIdx].tasks[tIdx].checked) { |
| navigator.vibrate(10); |
| } |
| }; |
| |
| const resetProgress = () => { |
| if(confirm('确定要清空当前进度吗?')) { |
| sections.value.forEach(s => s.tasks.forEach(t => t.checked = false)); |
| localStorage.removeItem(STORAGE_KEY); |
| } |
| }; |
| |
| |
| const triggerConfetti = () => { |
| const colors = ['#${data.theme === 'indigo' ? '4f46e5' : '10b981'}', '#fbbf24', '#ef4444', '#3b82f6']; |
| let particles = []; |
| for(let i=0; i<100; i++) { |
| particles.push({ |
| x: Math.random() * window.innerWidth, |
| y: -20, |
| vx: Math.random() * 4 - 2, |
| vy: Math.random() * 4 + 2, |
| color: colors[Math.floor(Math.random() * colors.length)], |
| size: Math.random() * 5 + 2 |
| }); |
| } |
| |
| const animate = () => { |
| if(particles.length === 0) return; |
| confettiCtx.clearRect(0, 0, window.innerWidth, window.innerHeight); |
| particles.forEach((p, i) => { |
| p.x += p.vx; |
| p.y += p.vy; |
| confettiCtx.fillStyle = p.color; |
| confettiCtx.fillRect(p.x, p.y, p.size, p.size); |
| if(p.y > window.innerHeight) particles.splice(i, 1); |
| }); |
| requestAnimationFrame(animate); |
| }; |
| animate(); |
| }; |
| |
| return { |
| sections, |
| toggleTask, |
| progress, |
| totalCount, |
| completedCount, |
| resetProgress, |
| scrolled |
| }; |
| } |
| }).mount('#app'); |
| <\/script> |
| </body> |
| </html>`; |
| } |
| </script> |
| </body> |
| </html> |
|
|