duqing2026 commited on
Commit
f092a9b
·
0 Parent(s):

Initial commit

Browse files
Files changed (6) hide show
  1. .gitignore +5 -0
  2. Dockerfile +11 -0
  3. README.md +54 -0
  4. app.py +41 -0
  5. templates/builder.html +327 -0
  6. 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>