Spaces:
Sleeping
Sleeping
Commit
·
f092a9b
0
Parent(s):
Initial commit
Browse files- .gitignore +5 -0
- Dockerfile +11 -0
- README.md +54 -0
- app.py +41 -0
- templates/builder.html +327 -0
- templates/export_template.html +253 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.DS_Store
|
| 4 |
+
.env
|
| 5 |
+
venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY . /app
|
| 6 |
+
|
| 7 |
+
RUN pip install --no-cache-dir flask
|
| 8 |
+
|
| 9 |
+
EXPOSE 7860
|
| 10 |
+
|
| 11 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: 交互式清单专家 (Interactive Checklist Pro)
|
| 3 |
+
emoji: ✅
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
short_description: 可视化构建交互式清单,一键导出单文件 HTML 应用(Local-First & 保存进度)。
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# ✅ 交互式清单专家 (Interactive Checklist Pro)
|
| 12 |
+
|
| 13 |
+
> **零代码构建、一键导出、永久保存进度的交互式清单应用。**
|
| 14 |
+
|
| 15 |
+
Interactive Checklist Pro 是一个为内容创作者、教育者和独立开发者设计的工具。它可以帮助你将枯燥的 PDF/文本清单转化为**精美的、可交互的、自动保存进度的 Web 应用**。
|
| 16 |
+
|
| 17 |
+
生成的清单是一个**单文件 HTML**,无需服务器,可以直接发给用户、部署到静态网站或作为数字产品出售。
|
| 18 |
+
|
| 19 |
+
## ✨ 核心功能
|
| 20 |
+
|
| 21 |
+
* **可视化构建器**:所见即所得的编辑界面,支持拖拽排序(通过按钮)、分组管理。
|
| 22 |
+
* **单文件导出**:生成一个独立的 `.html` 文件,包含所有逻辑和样式。
|
| 23 |
+
* **进度自动保存**:导出的清单使用 `localStorage` 自动记录用户的打钩状态,刷新页面不丢失。
|
| 24 |
+
* **资源链接集成**:为每个检查项添加备注和外部链接。
|
| 25 |
+
* **完赛奖励**:当用户完成 100% 进度时,触发庆祝撒花特效 🎉。
|
| 26 |
+
* **多主题配色**:内置 Indigo, Blue, Emerald, Rose 等多种现代配色方案。
|
| 27 |
+
|
| 28 |
+
## 🚀 使用场景
|
| 29 |
+
|
| 30 |
+
1. **作为 Lead Magnet (引流磁铁)**:制作 "2024 SEO 终极检查表" 或 "新媒体运营 SOP",免费分发以获取潜在客户。
|
| 31 |
+
2. **作为数字产品**:制作高价值的 "独立开发发布清单" 或 "婚礼筹备全流程",直接出售 HTML 文件。
|
| 32 |
+
3. **内部 SOP 管理**:为团队制作标准操作流程清单,分发给员工使用。
|
| 33 |
+
|
| 34 |
+
## 🛠️ 技术栈
|
| 35 |
+
|
| 36 |
+
* **Builder (编辑器)**: Flask (Python), Vue 3, Tailwind CSS.
|
| 37 |
+
* **Exported App (导出的清单)**: Vanilla JS (原生 JavaScript), Tailwind CSS (CDN), Canvas Confetti.
|
| 38 |
+
|
| 39 |
+
## 📦 部署与运行
|
| 40 |
+
|
| 41 |
+
### Hugging Face Spaces
|
| 42 |
+
|
| 43 |
+
本项目已配置 Dockerfile,可直接部署到 Hugging Face Spaces。
|
| 44 |
+
|
| 45 |
+
### 本地运行
|
| 46 |
+
|
| 47 |
+
1. 克隆项目
|
| 48 |
+
2. 安装依赖: `pip install flask`
|
| 49 |
+
3. 运行: `python app.py`
|
| 50 |
+
4. 访问: `http://localhost:7860`
|
| 51 |
+
|
| 52 |
+
## 📝 License
|
| 53 |
+
|
| 54 |
+
MIT License
|
app.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, send_file, Response
|
| 2 |
+
import json
|
| 3 |
+
import io
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
app = Flask(__name__)
|
| 7 |
+
app.secret_key = "checklist_pro_secret_key"
|
| 8 |
+
|
| 9 |
+
@app.route('/')
|
| 10 |
+
def index():
|
| 11 |
+
return render_template('builder.html')
|
| 12 |
+
|
| 13 |
+
@app.route('/download', methods=['POST'])
|
| 14 |
+
def download():
|
| 15 |
+
data = request.json
|
| 16 |
+
|
| 17 |
+
# Render the standalone template with the data
|
| 18 |
+
# We pass the data as a JSON string to be embedded in the JS
|
| 19 |
+
checklist_data_json = json.dumps(data, ensure_ascii=False)
|
| 20 |
+
|
| 21 |
+
html_content = render_template(
|
| 22 |
+
'export_template.html',
|
| 23 |
+
checklist_data=data,
|
| 24 |
+
checklist_data_json=checklist_data_json
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Create a file-like object
|
| 28 |
+
buffer = io.BytesIO()
|
| 29 |
+
buffer.write(html_content.encode('utf-8'))
|
| 30 |
+
buffer.seek(0)
|
| 31 |
+
|
| 32 |
+
return send_file(
|
| 33 |
+
buffer,
|
| 34 |
+
as_attachment=True,
|
| 35 |
+
download_name='checklist.html',
|
| 36 |
+
mimetype='text/html'
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if __name__ == '__main__':
|
| 40 |
+
port = int(os.environ.get('PORT', 7860))
|
| 41 |
+
app.run(host='0.0.0.0', port=port, debug=True)
|
templates/builder.html
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>交互式清单专家 (Interactive Checklist Pro)</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 9 |
+
<!-- Phosphor Icons -->
|
| 10 |
+
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
| 11 |
+
<style>
|
| 12 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 13 |
+
body { font-family: 'Inter', sans-serif; }
|
| 14 |
+
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
| 15 |
+
</style>
|
| 16 |
+
</head>
|
| 17 |
+
<body class="bg-gray-50 text-gray-800 h-screen flex flex-col overflow-hidden">
|
| 18 |
+
|
| 19 |
+
<div id="app" class="flex-1 flex flex-col h-full">
|
| 20 |
+
<!-- Header -->
|
| 21 |
+
<header class="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 shrink-0 z-10">
|
| 22 |
+
<div class="flex items-center gap-3">
|
| 23 |
+
<div class="bg-indigo-600 text-white p-2 rounded-lg">
|
| 24 |
+
<i class="ph ph-check-square text-xl"></i>
|
| 25 |
+
</div>
|
| 26 |
+
<h1 class="text-xl font-bold text-gray-900 tracking-tight">Interactive Checklist Pro</h1>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="flex items-center gap-4">
|
| 29 |
+
<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">
|
| 30 |
+
<i class="ph ph-download-simple" v-if="!isExporting"></i>
|
| 31 |
+
<i class="ph ph-spinner animate-spin" v-else></i>
|
| 32 |
+
{{ isExporting ? '生成中...' : '导出单文件 HTML' }}
|
| 33 |
+
</button>
|
| 34 |
+
</div>
|
| 35 |
+
</header>
|
| 36 |
+
|
| 37 |
+
<!-- Main Content -->
|
| 38 |
+
<div class="flex-1 flex overflow-hidden">
|
| 39 |
+
|
| 40 |
+
<!-- Left: Editor -->
|
| 41 |
+
<div class="w-1/2 bg-white border-r border-gray-200 flex flex-col overflow-hidden">
|
| 42 |
+
<div class="p-6 overflow-y-auto custom-scrollbar flex-1 pb-20">
|
| 43 |
+
|
| 44 |
+
<!-- Global Settings -->
|
| 45 |
+
<section class="mb-8">
|
| 46 |
+
<h2 class="text-sm uppercase tracking-wider text-gray-500 font-semibold mb-4">基本信息</h2>
|
| 47 |
+
<div class="space-y-4">
|
| 48 |
+
<div>
|
| 49 |
+
<label class="block text-sm font-medium text-gray-700 mb-1">清单标题</label>
|
| 50 |
+
<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 终极检查表">
|
| 51 |
+
</div>
|
| 52 |
+
<div>
|
| 53 |
+
<label class="block text-sm font-medium text-gray-700 mb-1">描述 / 引言</label>
|
| 54 |
+
<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>
|
| 55 |
+
</div>
|
| 56 |
+
<div>
|
| 57 |
+
<label class="block text-sm font-medium text-gray-700 mb-1">作者 / 品牌</label>
|
| 58 |
+
<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">
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Theme Selection -->
|
| 62 |
+
<div>
|
| 63 |
+
<label class="block text-sm font-medium text-gray-700 mb-2">主题色</label>
|
| 64 |
+
<div class="flex gap-3">
|
| 65 |
+
<button v-for="color in themeColors" :key="color.value"
|
| 66 |
+
@click="checklist.theme = color.value"
|
| 67 |
+
class="w-8 h-8 rounded-full border-2 transition-all"
|
| 68 |
+
:class="[color.bgClass, checklist.theme === color.value ? 'border-gray-900 scale-110' : 'border-transparent opacity-70 hover:opacity-100']">
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</section>
|
| 74 |
+
|
| 75 |
+
<!-- Content Editor -->
|
| 76 |
+
<section>
|
| 77 |
+
<div class="flex items-center justify-between mb-4">
|
| 78 |
+
<h2 class="text-sm uppercase tracking-wider text-gray-500 font-semibold">清单内容</h2>
|
| 79 |
+
<button @click="addGroup" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1">
|
| 80 |
+
<i class="ph ph-plus"></i> 添加分组
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="space-y-6">
|
| 85 |
+
<div v-for="(group, gIndex) in checklist.groups" :key="gIndex" class="border border-gray-200 rounded-xl bg-gray-50 overflow-hidden">
|
| 86 |
+
<!-- Group Header -->
|
| 87 |
+
<div class="bg-gray-100 p-4 border-b border-gray-200 flex items-start gap-3">
|
| 88 |
+
<div class="flex flex-col gap-1 pt-1">
|
| 89 |
+
<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>
|
| 90 |
+
<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>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="flex-1">
|
| 93 |
+
<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="分组名称 (例如:准备工作)">
|
| 94 |
+
</div>
|
| 95 |
+
<button @click="removeGroup(gIndex)" class="text-red-400 hover:text-red-600 p-1"><i class="ph ph-trash"></i></button>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<!-- Items List -->
|
| 99 |
+
<div class="p-4 space-y-3">
|
| 100 |
+
<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">
|
| 101 |
+
<div class="flex flex-col gap-0.5 pt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 102 |
+
<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>
|
| 103 |
+
<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>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="flex-1 space-y-2">
|
| 107 |
+
<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="检查项内容...">
|
| 108 |
+
|
| 109 |
+
<!-- Optional Fields Toggle -->
|
| 110 |
+
<div class="flex gap-4 text-xs">
|
| 111 |
+
<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="备注/提示 (可选)">
|
| 112 |
+
<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 (可选)">
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<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>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<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">
|
| 120 |
+
<i class="ph ph-plus-circle"></i> 添加检查项
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</section>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<!-- Right: Preview -->
|
| 130 |
+
<div class="w-1/2 bg-gray-100 flex flex-col overflow-hidden relative">
|
| 131 |
+
<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">
|
| 132 |
+
实时预览 (Export Preview)
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div class="flex-1 overflow-y-auto p-8 flex justify-center">
|
| 136 |
+
<!-- Phone/Tablet Frame -->
|
| 137 |
+
<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]">
|
| 138 |
+
<!-- App Header -->
|
| 139 |
+
<div class="p-6 text-white transition-colors duration-300 shrink-0" :class="currentThemeClass">
|
| 140 |
+
<div class="text-xs opacity-80 mb-1 uppercase tracking-wider">{{ checklist.author || 'Author Name' }}</div>
|
| 141 |
+
<h2 class="text-2xl font-bold leading-tight">{{ checklist.title || 'Checklist Title' }}</h2>
|
| 142 |
+
<p class="text-sm opacity-90 mt-2 line-clamp-2" v-if="checklist.description">{{ checklist.description }}</p>
|
| 143 |
+
|
| 144 |
+
<!-- Progress Bar Preview -->
|
| 145 |
+
<div class="mt-6">
|
| 146 |
+
<div class="flex justify-between text-xs font-medium mb-1 opacity-90">
|
| 147 |
+
<span>进度 0%</span>
|
| 148 |
+
<span>0/{{ totalItems }}</span>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="h-2 bg-black/20 rounded-full overflow-hidden">
|
| 151 |
+
<div class="h-full bg-white/90 w-0"></div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- App Content -->
|
| 157 |
+
<div class="flex-1 overflow-y-auto p-4 space-y-6 bg-white">
|
| 158 |
+
<div v-for="(group, gIndex) in checklist.groups" :key="gIndex">
|
| 159 |
+
<h3 class="font-bold text-gray-800 mb-3 flex items-center gap-2">
|
| 160 |
+
<span class="w-1.5 h-4 rounded-full" :class="currentThemeTextClass.replace('text-', 'bg-')"></span>
|
| 161 |
+
{{ group.title || 'Untitled Group' }}
|
| 162 |
+
</h3>
|
| 163 |
+
<div class="space-y-2">
|
| 164 |
+
<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">
|
| 165 |
+
<div class="w-5 h-5 rounded border-2 border-gray-300 shrink-0 mt-0.5"></div>
|
| 166 |
+
<div class="flex-1">
|
| 167 |
+
<div class="text-gray-700 text-sm font-medium">{{ item.text || 'Item text' }}</div>
|
| 168 |
+
<div v-if="item.note" class="text-xs text-gray-500 mt-1">{{ item.note }}</div>
|
| 169 |
+
<div v-if="item.link" class="text-xs mt-1 text-blue-500 underline truncate">Resource Link</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div v-if="checklist.groups.length === 0" class="text-center text-gray-400 py-10 text-sm">
|
| 176 |
+
暂无内容,请在左侧添加
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<!-- Footer -->
|
| 181 |
+
<div class="bg-gray-50 p-3 text-center text-xs text-gray-400 border-t border-gray-100 shrink-0">
|
| 182 |
+
Powered by Interactive Checklist Pro
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<script>
|
| 191 |
+
const { createApp, ref, computed } = Vue;
|
| 192 |
+
|
| 193 |
+
createApp({
|
| 194 |
+
setup() {
|
| 195 |
+
const checklist = ref({
|
| 196 |
+
title: '我的超级清单',
|
| 197 |
+
description: '这是一个帮您达成目标的步骤清单。',
|
| 198 |
+
author: '',
|
| 199 |
+
theme: 'indigo',
|
| 200 |
+
groups: [
|
| 201 |
+
{
|
| 202 |
+
title: '第一阶段:准备',
|
| 203 |
+
items: [
|
| 204 |
+
{ text: '确定目标', note: '目标需要符合 SMART 原则', link: '' },
|
| 205 |
+
{ text: '收集资料', note: '', link: 'https://google.com' }
|
| 206 |
+
]
|
| 207 |
+
}
|
| 208 |
+
]
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
const isExporting = ref(false);
|
| 212 |
+
|
| 213 |
+
const themeColors = [
|
| 214 |
+
{ value: 'indigo', bgClass: 'bg-indigo-600' },
|
| 215 |
+
{ value: 'blue', bgClass: 'bg-blue-600' },
|
| 216 |
+
{ value: 'emerald', bgClass: 'bg-emerald-600' },
|
| 217 |
+
{ value: 'rose', bgClass: 'bg-rose-600' },
|
| 218 |
+
{ value: 'amber', bgClass: 'bg-amber-600' },
|
| 219 |
+
{ value: 'slate', bgClass: 'bg-slate-800' },
|
| 220 |
+
];
|
| 221 |
+
|
| 222 |
+
const currentThemeClass = computed(() => {
|
| 223 |
+
const map = {
|
| 224 |
+
'indigo': 'bg-indigo-600',
|
| 225 |
+
'blue': 'bg-blue-600',
|
| 226 |
+
'emerald': 'bg-emerald-600',
|
| 227 |
+
'rose': 'bg-rose-600',
|
| 228 |
+
'amber': 'bg-amber-600',
|
| 229 |
+
'slate': 'bg-slate-800',
|
| 230 |
+
};
|
| 231 |
+
return map[checklist.value.theme] || 'bg-indigo-600';
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
const currentThemeTextClass = computed(() => {
|
| 235 |
+
const map = {
|
| 236 |
+
'indigo': 'text-indigo-600',
|
| 237 |
+
'blue': 'text-blue-600',
|
| 238 |
+
'emerald': 'text-emerald-600',
|
| 239 |
+
'rose': 'text-rose-600',
|
| 240 |
+
'amber': 'text-amber-600',
|
| 241 |
+
'slate': 'text-slate-800',
|
| 242 |
+
};
|
| 243 |
+
return map[checklist.value.theme] || 'text-indigo-600';
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
const totalItems = computed(() => {
|
| 247 |
+
return checklist.value.groups.reduce((acc, g) => acc + g.items.length, 0);
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
// Actions
|
| 251 |
+
const addGroup = () => {
|
| 252 |
+
checklist.value.groups.push({ title: '', items: [] });
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const removeGroup = (index) => {
|
| 256 |
+
if(confirm('确定删除此分组吗?')) checklist.value.groups.splice(index, 1);
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
const moveGroup = (index, direction) => {
|
| 260 |
+
const newIndex = index + direction;
|
| 261 |
+
if (newIndex >= 0 && newIndex < checklist.value.groups.length) {
|
| 262 |
+
const temp = checklist.value.groups[index];
|
| 263 |
+
checklist.value.groups[index] = checklist.value.groups[newIndex];
|
| 264 |
+
checklist.value.groups[newIndex] = temp;
|
| 265 |
+
}
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
const addItem = (gIndex) => {
|
| 269 |
+
checklist.value.groups[gIndex].items.push({ text: '', note: '', link: '' });
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const removeItem = (gIndex, iIndex) => {
|
| 273 |
+
checklist.value.groups[gIndex].items.splice(iIndex, 1);
|
| 274 |
+
};
|
| 275 |
+
|
| 276 |
+
const moveItem = (gIndex, iIndex, direction) => {
|
| 277 |
+
const items = checklist.value.groups[gIndex].items;
|
| 278 |
+
const newIndex = iIndex + direction;
|
| 279 |
+
if (newIndex >= 0 && newIndex < items.length) {
|
| 280 |
+
const temp = items[iIndex];
|
| 281 |
+
items[iIndex] = items[newIndex];
|
| 282 |
+
items[newIndex] = temp;
|
| 283 |
+
}
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
const downloadHTML = async () => {
|
| 287 |
+
isExporting.value = true;
|
| 288 |
+
try {
|
| 289 |
+
const response = await fetch('/download', {
|
| 290 |
+
method: 'POST',
|
| 291 |
+
headers: { 'Content-Type': 'application/json' },
|
| 292 |
+
body: JSON.stringify(checklist.value)
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
if (!response.ok) throw new Error('Export failed');
|
| 296 |
+
|
| 297 |
+
const blob = await response.blob();
|
| 298 |
+
const url = window.URL.createObjectURL(blob);
|
| 299 |
+
const a = document.createElement('a');
|
| 300 |
+
a.href = url;
|
| 301 |
+
a.download = `checklist-${Date.now()}.html`;
|
| 302 |
+
document.body.appendChild(a);
|
| 303 |
+
a.click();
|
| 304 |
+
document.body.removeChild(a);
|
| 305 |
+
window.URL.revokeObjectURL(url);
|
| 306 |
+
} catch (e) {
|
| 307 |
+
alert('导出失败: ' + e.message);
|
| 308 |
+
} finally {
|
| 309 |
+
isExporting.value = false;
|
| 310 |
+
}
|
| 311 |
+
};
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
checklist,
|
| 315 |
+
themeColors,
|
| 316 |
+
currentThemeClass,
|
| 317 |
+
currentThemeTextClass,
|
| 318 |
+
totalItems,
|
| 319 |
+
addGroup, removeGroup, moveGroup,
|
| 320 |
+
addItem, removeItem, moveItem,
|
| 321 |
+
downloadHTML, isExporting
|
| 322 |
+
};
|
| 323 |
+
}
|
| 324 |
+
}).mount('#app');
|
| 325 |
+
</script>
|
| 326 |
+
</body>
|
| 327 |
+
</html>
|
templates/export_template.html
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ checklist_data.title }}</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>
|
| 10 |
+
<style>
|
| 11 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 12 |
+
body { font-family: 'Inter', sans-serif; }
|
| 13 |
+
.checkbox-wrapper input:checked + div {
|
| 14 |
+
background-color: currentColor;
|
| 15 |
+
border-color: currentColor;
|
| 16 |
+
}
|
| 17 |
+
.checkbox-wrapper input:checked + div svg {
|
| 18 |
+
display: block;
|
| 19 |
+
}
|
| 20 |
+
/* Smooth transitions */
|
| 21 |
+
.transition-all-300 { transition: all 0.3s ease; }
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body class="bg-gray-50 min-h-screen pb-20">
|
| 25 |
+
|
| 26 |
+
<!-- Data Injection -->
|
| 27 |
+
<script>
|
| 28 |
+
const checklistData = {{ checklist_data_json|safe }};
|
| 29 |
+
const STORAGE_KEY = 'checklist_pro_' + btoa(encodeURIComponent(checklistData.title)).slice(0, 16);
|
| 30 |
+
</script>
|
| 31 |
+
|
| 32 |
+
<div id="app" class="max-w-md mx-auto bg-white min-h-screen shadow-2xl relative">
|
| 33 |
+
|
| 34 |
+
<!-- Header -->
|
| 35 |
+
<header id="header-bg" class="text-white p-8 pt-12 rounded-b-[2rem] shadow-lg relative overflow-hidden transition-colors duration-300">
|
| 36 |
+
<div class="relative z-10">
|
| 37 |
+
<div class="text-xs opacity-80 mb-2 font-medium tracking-wider uppercase">{{ checklist_data.author }}</div>
|
| 38 |
+
<h1 class="text-3xl font-bold leading-tight mb-3">{{ checklist_data.title }}</h1>
|
| 39 |
+
<p class="text-white/90 text-sm leading-relaxed">{{ checklist_data.description }}</p>
|
| 40 |
+
|
| 41 |
+
<!-- Progress -->
|
| 42 |
+
<div class="mt-8">
|
| 43 |
+
<div class="flex justify-between text-xs font-bold mb-2 opacity-90">
|
| 44 |
+
<span id="progress-text">0% 完成</span>
|
| 45 |
+
<span id="count-text">0/0</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="h-2.5 bg-black/20 rounded-full overflow-hidden backdrop-blur-sm">
|
| 48 |
+
<div id="progress-bar" class="h-full bg-white/95 w-0 transition-all duration-500 ease-out rounded-full"></div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Decorative Circles -->
|
| 54 |
+
<div class="absolute top-0 right-0 -mr-10 -mt-10 w-40 h-40 bg-white/10 rounded-full blur-2xl"></div>
|
| 55 |
+
<div class="absolute bottom-0 left-0 -ml-10 -mb-5 w-32 h-32 bg-black/10 rounded-full blur-xl"></div>
|
| 56 |
+
</header>
|
| 57 |
+
|
| 58 |
+
<!-- Content -->
|
| 59 |
+
<main class="p-6 space-y-8">
|
| 60 |
+
<div id="groups-container" class="space-y-8">
|
| 61 |
+
<!-- Groups will be rendered here -->
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<!-- Reset Button -->
|
| 65 |
+
<div class="pt-8 pb-4 text-center">
|
| 66 |
+
<button onclick="resetProgress()" class="text-xs text-gray-400 hover:text-gray-600 underline decoration-dotted">
|
| 67 |
+
重置进度 (Clear Progress)
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
</main>
|
| 71 |
+
|
| 72 |
+
<!-- Footer -->
|
| 73 |
+
<footer class="text-center p-6 text-xs text-gray-300 border-t border-gray-50">
|
| 74 |
+
Created with Interactive Checklist Pro
|
| 75 |
+
</footer>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Logic -->
|
| 79 |
+
<script>
|
| 80 |
+
// Theme Mapping
|
| 81 |
+
const themeMap = {
|
| 82 |
+
'indigo': 'bg-indigo-600',
|
| 83 |
+
'blue': 'bg-blue-600',
|
| 84 |
+
'emerald': 'bg-emerald-600',
|
| 85 |
+
'rose': 'bg-rose-600',
|
| 86 |
+
'amber': 'bg-amber-600',
|
| 87 |
+
'slate': 'bg-slate-800',
|
| 88 |
+
};
|
| 89 |
+
const textThemeMap = {
|
| 90 |
+
'indigo': 'text-indigo-600',
|
| 91 |
+
'blue': 'text-blue-600',
|
| 92 |
+
'emerald': 'text-emerald-600',
|
| 93 |
+
'rose': 'text-rose-600',
|
| 94 |
+
'amber': 'text-amber-600',
|
| 95 |
+
'slate': 'text-slate-800',
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const themeClass = themeMap[checklistData.theme] || 'bg-indigo-600';
|
| 99 |
+
const textClass = textThemeMap[checklistData.theme] || 'text-indigo-600';
|
| 100 |
+
|
| 101 |
+
// Apply Theme
|
| 102 |
+
document.getElementById('header-bg').classList.add(themeClass);
|
| 103 |
+
|
| 104 |
+
// State
|
| 105 |
+
let state = {
|
| 106 |
+
checkedItems: [] // array of item IDs (groupIndex-itemIndex)
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// Load State
|
| 110 |
+
const saved = localStorage.getItem(STORAGE_KEY);
|
| 111 |
+
if (saved) {
|
| 112 |
+
try {
|
| 113 |
+
state = JSON.parse(saved);
|
| 114 |
+
} catch(e) { console.error('Load failed', e); }
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Render
|
| 118 |
+
const container = document.getElementById('groups-container');
|
| 119 |
+
let totalItemsCount = 0;
|
| 120 |
+
|
| 121 |
+
checklistData.groups.forEach((group, gIndex) => {
|
| 122 |
+
const groupEl = document.createElement('div');
|
| 123 |
+
|
| 124 |
+
// Group Title
|
| 125 |
+
const titleHTML = `
|
| 126 |
+
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
| 127 |
+
<span class="w-1.5 h-5 rounded-full ${themeClass}"></span>
|
| 128 |
+
${group.title}
|
| 129 |
+
</h2>
|
| 130 |
+
`;
|
| 131 |
+
|
| 132 |
+
// Items
|
| 133 |
+
let itemsHTML = '<div class="space-y-3">';
|
| 134 |
+
group.items.forEach((item, iIndex) => {
|
| 135 |
+
totalItemsCount++;
|
| 136 |
+
const itemId = `${gIndex}-${iIndex}`;
|
| 137 |
+
const isChecked = state.checkedItems.includes(itemId);
|
| 138 |
+
|
| 139 |
+
itemsHTML += `
|
| 140 |
+
<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' : ''}">
|
| 141 |
+
<input type="checkbox" class="hidden"
|
| 142 |
+
onchange="toggleItem('${itemId}')"
|
| 143 |
+
${isChecked ? 'checked' : ''}>
|
| 144 |
+
|
| 145 |
+
<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">
|
| 146 |
+
<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>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="flex-1 pt-0.5 ${isChecked ? 'opacity-50 line-through grayscale' : ''} transition-all duration-300">
|
| 150 |
+
<div class="text-gray-700 font-medium text-sm leading-snug">${item.text}</div>
|
| 151 |
+
${item.note ? `<div class="text-xs text-gray-500 mt-1">${item.note}</div>` : ''}
|
| 152 |
+
${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>` : ''}
|
| 153 |
+
</div>
|
| 154 |
+
</label>
|
| 155 |
+
`;
|
| 156 |
+
});
|
| 157 |
+
itemsHTML += '</div>';
|
| 158 |
+
|
| 159 |
+
groupEl.innerHTML = titleHTML + itemsHTML;
|
| 160 |
+
container.appendChild(groupEl);
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
// Update UI
|
| 164 |
+
function updateUI() {
|
| 165 |
+
const checkedCount = state.checkedItems.length;
|
| 166 |
+
const percentage = totalItemsCount === 0 ? 0 : Math.round((checkedCount / totalItemsCount) * 100);
|
| 167 |
+
|
| 168 |
+
document.getElementById('progress-bar').style.width = `${percentage}%`;
|
| 169 |
+
document.getElementById('progress-text').innerText = `${percentage}% 完成`;
|
| 170 |
+
document.getElementById('count-text').innerText = `${checkedCount}/${totalItemsCount}`;
|
| 171 |
+
|
| 172 |
+
// Check for completion
|
| 173 |
+
if (percentage === 100 && totalItemsCount > 0) {
|
| 174 |
+
triggerConfetti();
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Logic
|
| 179 |
+
window.toggleItem = (id) => {
|
| 180 |
+
if (state.checkedItems.includes(id)) {
|
| 181 |
+
state.checkedItems = state.checkedItems.filter(i => i !== id);
|
| 182 |
+
} else {
|
| 183 |
+
state.checkedItems.push(id);
|
| 184 |
+
}
|
| 185 |
+
save();
|
| 186 |
+
// Re-render specifically this item or just toggle class (Optimization: toggle class directly)
|
| 187 |
+
// For simplicity in this vanilla script, reloading page or complex DOM manipulation is overkill.
|
| 188 |
+
// But we need to update the visual state of the specific element immediately.
|
| 189 |
+
// The input 'checked' state handles the icon. We need to handle the strikethrough class.
|
| 190 |
+
const input = document.querySelector(`input[onchange="toggleItem('${id}')"]`);
|
| 191 |
+
const wrapper = input.closest('label');
|
| 192 |
+
const contentDiv = wrapper.querySelector('.flex-1');
|
| 193 |
+
|
| 194 |
+
if (state.checkedItems.includes(id)) {
|
| 195 |
+
contentDiv.classList.add('opacity-50', 'line-through', 'grayscale');
|
| 196 |
+
wrapper.classList.add('bg-gray-50/50');
|
| 197 |
+
} else {
|
| 198 |
+
contentDiv.classList.remove('opacity-50', 'line-through', 'grayscale');
|
| 199 |
+
wrapper.classList.remove('bg-gray-50/50');
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
updateUI();
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
function save() {
|
| 206 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
window.resetProgress = () => {
|
| 210 |
+
if(confirm('确定要清空所有进度吗?')) {
|
| 211 |
+
state.checkedItems = [];
|
| 212 |
+
save();
|
| 213 |
+
location.reload();
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
function triggerConfetti() {
|
| 218 |
+
const count = 200;
|
| 219 |
+
const defaults = {
|
| 220 |
+
origin: { y: 0.7 }
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
function fire(particleRatio, opts) {
|
| 224 |
+
confetti(Object.assign({}, defaults, opts, {
|
| 225 |
+
particleCount: Math.floor(count * particleRatio)
|
| 226 |
+
}));
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
fire(0.25, { spread: 26, startVelocity: 55 });
|
| 230 |
+
fire(0.2, { spread: 60 });
|
| 231 |
+
fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 });
|
| 232 |
+
fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 });
|
| 233 |
+
fire(0.1, { spread: 120, startVelocity: 45 });
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// Init
|
| 237 |
+
// We need to apply initial visual states (strikethrough) since we static rendered
|
| 238 |
+
// Actually, we should iterate and apply classes based on state on load.
|
| 239 |
+
state.checkedItems.forEach(id => {
|
| 240 |
+
const input = document.querySelector(`input[onchange="toggleItem('${id}')"]`);
|
| 241 |
+
if(input) {
|
| 242 |
+
input.checked = true;
|
| 243 |
+
const wrapper = input.closest('label');
|
| 244 |
+
const contentDiv = wrapper.querySelector('.flex-1');
|
| 245 |
+
contentDiv.classList.add('opacity-50', 'line-through', 'grayscale');
|
| 246 |
+
wrapper.classList.add('bg-gray-50/50');
|
| 247 |
+
}
|
| 248 |
+
});
|
| 249 |
+
updateUI();
|
| 250 |
+
|
| 251 |
+
</script>
|
| 252 |
+
</body>
|
| 253 |
+
</html>
|