Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>{{ checklist_data.title | default('交互式清单') }}</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/@phosphor-icons/web"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></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; } | |
| .checkbox-wrapper input:checked + div { | |
| background-color: currentColor; | |
| border-color: currentColor; | |
| } | |
| .checkbox-wrapper input:checked + div svg { | |
| display: block; | |
| } | |
| /* Smooth transitions */ | |
| .transition-all-300 { transition: all 0.3s ease; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen pb-20"> | |
| <!-- Data Injection --> | |
| <script> | |
| const checklistData = {{ checklist_data | tojson }}; | |
| const STORAGE_KEY = 'checklist_pro_' + btoa(encodeURIComponent(checklistData.title || 'default')).slice(0, 16); | |
| </script> | |
| <div id="app" class="max-w-md mx-auto bg-white min-h-screen shadow-2xl relative"> | |
| <!-- Header --> | |
| <header id="header-bg" class="text-white p-8 pt-12 rounded-b-[2rem] shadow-lg relative overflow-hidden transition-colors duration-300"> | |
| <div class="relative z-10"> | |
| <div class="text-xs opacity-80 mb-2 font-medium tracking-wider uppercase">{{ checklist_data.author | default('') }}</div> | |
| <h1 class="text-3xl font-bold leading-tight mb-3">{{ checklist_data.title | default('Checklist') }}</h1> | |
| <p class="text-white/90 text-sm leading-relaxed">{{ checklist_data.description | default('') }}</p> | |
| <!-- Progress --> | |
| <div class="mt-8"> | |
| <div class="flex justify-between text-xs font-bold mb-2 opacity-90"> | |
| <span id="progress-text">0% 完成</span> | |
| <span id="count-text">0/0</span> | |
| </div> | |
| <div class="h-2.5 bg-black/20 rounded-full overflow-hidden backdrop-blur-sm"> | |
| <div id="progress-bar" class="h-full bg-white/95 w-0 transition-all duration-500 ease-out rounded-full"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Decorative Circles --> | |
| <div class="absolute top-0 right-0 -mr-10 -mt-10 w-40 h-40 bg-white/10 rounded-full blur-2xl"></div> | |
| <div class="absolute bottom-0 left-0 -ml-10 -mb-5 w-32 h-32 bg-black/10 rounded-full blur-xl"></div> | |
| </header> | |
| <!-- Content --> | |
| <main class="p-6 space-y-8"> | |
| <div id="groups-container" class="space-y-8"> | |
| <!-- Groups will be rendered here --> | |
| </div> | |
| <!-- Reset Button --> | |
| <div class="pt-8 pb-4 text-center"> | |
| <button onclick="resetProgress()" class="text-xs text-gray-400 hover:text-gray-600 underline decoration-dotted"> | |
| 重置进度 (Clear Progress) | |
| </button> | |
| </div> | |
| </main> | |
| <!-- Footer --> | |
| <footer class="text-center p-6 text-xs text-gray-300 border-t border-gray-50"> | |
| Created with Interactive Checklist Pro | |
| </footer> | |
| </div> | |
| <!-- Logic --> | |
| <script> | |
| // Theme Mapping | |
| const themeMap = { | |
| 'indigo': 'bg-indigo-600', | |
| 'blue': 'bg-blue-600', | |
| 'emerald': 'bg-emerald-600', | |
| 'rose': 'bg-rose-600', | |
| 'amber': 'bg-amber-600', | |
| 'slate': 'bg-slate-800', | |
| }; | |
| const textThemeMap = { | |
| 'indigo': 'text-indigo-600', | |
| 'blue': 'text-blue-600', | |
| 'emerald': 'text-emerald-600', | |
| 'rose': 'text-rose-600', | |
| 'amber': 'text-amber-600', | |
| 'slate': 'text-slate-800', | |
| }; | |
| const themeClass = themeMap[checklistData.theme] || 'bg-indigo-600'; | |
| const textClass = textThemeMap[checklistData.theme] || 'text-indigo-600'; | |
| // Apply Theme | |
| document.getElementById('header-bg').classList.add(themeClass); | |
| // State | |
| let state = { | |
| checkedItems: [] // array of item IDs (groupIndex-itemIndex) | |
| }; | |
| // Load State | |
| const saved = localStorage.getItem(STORAGE_KEY); | |
| if (saved) { | |
| try { | |
| state = JSON.parse(saved); | |
| } catch(e) { console.error('Load failed', e); } | |
| } | |
| // Render | |
| const container = document.getElementById('groups-container'); | |
| let totalItemsCount = 0; | |
| checklistData.groups.forEach((group, gIndex) => { | |
| const groupEl = document.createElement('div'); | |
| // Group Title | |
| const titleHTML = ` | |
| <h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2"> | |
| <span class="w-1.5 h-5 rounded-full ${themeClass}"></span> | |
| ${group.title} | |
| </h2> | |
| `; | |
| // Items | |
| let itemsHTML = '<div class="space-y-3">'; | |
| group.items.forEach((item, iIndex) => { | |
| totalItemsCount++; | |
| const itemId = `${gIndex}-${iIndex}`; | |
| const isChecked = state.checkedItems.includes(itemId); | |
| itemsHTML += ` | |
| <label class="checkbox-wrapper flex items-start gap-3 p-3 rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm bg-white transition-all-300 cursor-pointer select-none group ${isChecked ? 'bg-gray-50/50' : ''}"> | |
| <input type="checkbox" class="hidden" | |
| onchange="toggleItem('${itemId}')" | |
| ${isChecked ? 'checked' : ''}> | |
| <div class="w-6 h-6 rounded-lg border-2 border-gray-200 flex items-center justify-center text-white shrink-0 transition-colors ${textClass.replace('text-', 'text-')} group-hover:border-gray-300"> | |
| <svg class="w-4 h-4 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg> | |
| </div> | |
| <div class="flex-1 pt-0.5 ${isChecked ? 'opacity-50 line-through grayscale' : ''} transition-all duration-300"> | |
| <div class="text-gray-700 font-medium text-sm leading-snug">${item.text}</div> | |
| ${item.note ? `<div class="text-xs text-gray-500 mt-1">${item.note}</div>` : ''} | |
| ${item.link ? `<a href="${item.link}" target="_blank" class="inline-flex items-center gap-1 text-xs mt-1.5 ${textClass} hover:underline" onclick="event.stopPropagation()"><i class="ph ph-link"></i> Resource</a>` : ''} | |
| </div> | |
| </label> | |
| `; | |
| }); | |
| itemsHTML += '</div>'; | |
| groupEl.innerHTML = titleHTML + itemsHTML; | |
| container.appendChild(groupEl); | |
| }); | |
| // Update UI | |
| function updateUI() { | |
| const checkedCount = state.checkedItems.length; | |
| const percentage = totalItemsCount === 0 ? 0 : Math.round((checkedCount / totalItemsCount) * 100); | |
| document.getElementById('progress-bar').style.width = `${percentage}%`; | |
| document.getElementById('progress-text').innerText = `${percentage}% 完成`; | |
| document.getElementById('count-text').innerText = `${checkedCount}/${totalItemsCount}`; | |
| // Check for completion | |
| if (percentage === 100 && totalItemsCount > 0) { | |
| triggerConfetti(); | |
| } | |
| } | |
| // Logic | |
| window.toggleItem = (id) => { | |
| if (state.checkedItems.includes(id)) { | |
| state.checkedItems = state.checkedItems.filter(i => i !== id); | |
| } else { | |
| state.checkedItems.push(id); | |
| } | |
| save(); | |
| // Re-render specifically this item or just toggle class (Optimization: toggle class directly) | |
| // For simplicity in this vanilla script, reloading page or complex DOM manipulation is overkill. | |
| // But we need to update the visual state of the specific element immediately. | |
| // The input 'checked' state handles the icon. We need to handle the strikethrough class. | |
| const input = document.querySelector(`input[onchange="toggleItem('${id}')"]`); | |
| const wrapper = input.closest('label'); | |
| const contentDiv = wrapper.querySelector('.flex-1'); | |
| if (state.checkedItems.includes(id)) { | |
| contentDiv.classList.add('opacity-50', 'line-through', 'grayscale'); | |
| wrapper.classList.add('bg-gray-50/50'); | |
| } else { | |
| contentDiv.classList.remove('opacity-50', 'line-through', 'grayscale'); | |
| wrapper.classList.remove('bg-gray-50/50'); | |
| } | |
| updateUI(); | |
| }; | |
| function save() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); | |
| } | |
| window.resetProgress = () => { | |
| if(confirm('确定要清空所有进度吗?')) { | |
| state.checkedItems = []; | |
| save(); | |
| location.reload(); | |
| } | |
| }; | |
| function triggerConfetti() { | |
| const count = 200; | |
| const defaults = { | |
| origin: { y: 0.7 } | |
| }; | |
| function fire(particleRatio, opts) { | |
| confetti(Object.assign({}, defaults, opts, { | |
| particleCount: Math.floor(count * particleRatio) | |
| })); | |
| } | |
| fire(0.25, { spread: 26, startVelocity: 55 }); | |
| fire(0.2, { spread: 60 }); | |
| fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 }); | |
| fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 }); | |
| fire(0.1, { spread: 120, startVelocity: 45 }); | |
| } | |
| // Init | |
| // We need to apply initial visual states (strikethrough) since we static rendered | |
| // Actually, we should iterate and apply classes based on state on load. | |
| state.checkedItems.forEach(id => { | |
| const input = document.querySelector(`input[onchange="toggleItem('${id}')"]`); | |
| if(input) { | |
| input.checked = true; | |
| const wrapper = input.closest('label'); | |
| const contentDiv = wrapper.querySelector('.flex-1'); | |
| contentDiv.classList.add('opacity-50', 'line-through', 'grayscale'); | |
| wrapper.classList.add('bg-gray-50/50'); | |
| } | |
| }); | |
| updateUI(); | |
| </script> | |
| </body> | |
| </html> | |