duqing2026 commited on
Commit
a83f3b4
·
1 Parent(s): a4ec11a

Initial commit

Browse files
Files changed (5) hide show
  1. Dockerfile +10 -0
  2. README.md +56 -5
  3. app.py +16 -0
  4. requirements.txt +2 -0
  5. templates/index.html +386 -0
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt requirements.txt
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,61 @@
1
  ---
2
- title: Saas Comparison Studio
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SaaS Comparison Studio
3
+ emoji: ⚔️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ short_description: SaaS 竞品对比图工坊
9
  ---
10
 
11
+ # SaaS 竞品对比图工坊 (SaaS Comparison Studio)
12
+
13
+ 帮助 SaaS 创始人、独立开发者和市场营销人员快速生成高转化的竞品对比图(Battle Cards)。
14
+
15
+ ## ✨ 核心功能
16
+
17
+ * **可视化编辑器**:点击单元格即可切换状态(对勾 ✅、叉 ❌、减号 ➖、文字)。
18
+ * **灵活布局**:任意添加/删除竞品列和功能行。
19
+ * **强调高亮**:一键设置“我的产品”为高亮列,引导用户选择。
20
+ * **多主题支持**:内置简约白、深邃黑、暖色调等多种风格。
21
+ * **一键导出**:基于浏览器的高清图片导出 (PNG),保护隐私(无数据上传)。
22
+ * **本地存储**:自动保存编辑进度到 LocalStorage,防止丢失。
23
+
24
+ ## 🛠️ 技术栈
25
+
26
+ * **Backend**: Flask (Python)
27
+ * **Frontend**: Vue 3 + Tailwind CSS (via CDN)
28
+ * **Export**: html2canvas
29
+ * **Icons**: Phosphor Icons
30
+
31
+ ## 🚀 快速开始
32
+
33
+ ### Docker 部署 (推荐)
34
+
35
+ 本项目已配置 Dockerfile,可直接部署到 Hugging Face Spaces。
36
+
37
+ ```bash
38
+ docker build -t saas-comparison-studio .
39
+ docker run -p 7860:7860 saas-comparison-studio
40
+ ```
41
+
42
+ ### 本地开发
43
+
44
+ 1. 安装依赖:
45
+ ```bash
46
+ pip install -r requirements.txt
47
+ ```
48
+
49
+ 2. 运行应用:
50
+ ```bash
51
+ python app.py
52
+ ```
53
+
54
+ 3. 访问 `http://localhost:7860`
55
+
56
+ ## 📝 为什么做这个工具?
57
+
58
+ 在 SaaS 营销中,"Us vs Them" 页面是转化率最高的页面之一。但使用 PS 或 Figma 制作对比图既耗时又难以维护(每次功能更新都要改图)。这个工具旨在让任何人都能在 1 分钟内生成专业的竞品对比图。
59
+
60
+ ---
61
+ Built with ❤️ by Trae AI
app.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, send_from_directory
2
+ import os
3
+
4
+ app = Flask(__name__)
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/static/<path:path>')
11
+ def send_static(path):
12
+ return send_from_directory('static', path)
13
+
14
+ if __name__ == '__main__':
15
+ port = int(os.environ.get('PORT', 7860))
16
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>SaaS 竞品对比图工坊</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
10
+ <!-- Phosphor Icons -->
11
+ <script src="https://unpkg.com/@phosphor-icons/web"></script>
12
+ <style>
13
+ [contenteditable]:empty:before {
14
+ content: attr(placeholder);
15
+ color: #9ca3af;
16
+ cursor: text;
17
+ }
18
+ .cell-check { color: #10b981; }
19
+ .cell-cross { color: #ef4444; }
20
+ .cell-dash { color: #9ca3af; }
21
+ </style>
22
+ </head>
23
+ <body class="bg-gray-50 min-h-screen font-sans">
24
+ <div id="app" class="p-4 md:p-8 max-w-7xl mx-auto">
25
+ <!-- Header -->
26
+ <header class="mb-8 flex justify-between items-center">
27
+ <div>
28
+ <h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
29
+ <i class="ph ph-sword text-indigo-600"></i>
30
+ SaaS 竞品对比图工坊
31
+ </h1>
32
+ <p class="text-gray-500 mt-1">快速生成高转化的竞品对比图 (Battle Cards)</p>
33
+ </div>
34
+ <div class="flex gap-3">
35
+ <button @click="resetData" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
36
+ 重置
37
+ </button>
38
+ <button @click="exportImage" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
39
+ <i class="ph ph-download-simple"></i>
40
+ 导出图片
41
+ </button>
42
+ </div>
43
+ </header>
44
+
45
+ <!-- Main Editor -->
46
+ <div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
47
+ <!-- Sidebar Controls -->
48
+ <div class="lg:col-span-1 space-y-6">
49
+ <div class="bg-white p-5 rounded-xl shadow-sm border border-gray-200">
50
+ <h3 class="font-semibold text-gray-900 mb-4">外观设置</h3>
51
+
52
+ <div class="space-y-4">
53
+ <div>
54
+ <label class="block text-sm font-medium text-gray-700 mb-1">主题风格</label>
55
+ <div class="grid grid-cols-3 gap-2">
56
+ <button
57
+ v-for="t in themes"
58
+ :key="t.value"
59
+ @click="currentTheme = t.value"
60
+ :class="{'ring-2 ring-indigo-500 ring-offset-1': currentTheme === t.value}"
61
+ class="h-8 rounded border border-gray-200 text-xs font-medium"
62
+ :style="{background: t.bg, color: t.text}"
63
+ >
64
+ [[ t.label ]]
65
+ </button>
66
+ </div>
67
+ </div>
68
+
69
+ <div>
70
+ <label class="block text-sm font-medium text-gray-700 mb-1">强调色</label>
71
+ <div class="flex flex-wrap gap-2">
72
+ <button
73
+ v-for="color in colors"
74
+ :key="color"
75
+ @click="primaryColor = color"
76
+ class="w-6 h-6 rounded-full border border-gray-200"
77
+ :class="{'ring-2 ring-offset-1 ring-gray-400': primaryColor === color}"
78
+ :style="{backgroundColor: color}"
79
+ ></button>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="flex items-center gap-2">
84
+ <input type="checkbox" id="show-logo" v-model="showLogo" class="rounded text-indigo-600 focus:ring-indigo-500">
85
+ <label for="show-logo" class="text-sm text-gray-700">显示 Logo 占位符</label>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="bg-white p-5 rounded-xl shadow-sm border border-gray-200">
91
+ <h3 class="font-semibold text-gray-900 mb-4">数据管理</h3>
92
+ <div class="space-y-3">
93
+ <button @click="addColumn" class="w-full py-2 px-3 border border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-indigo-500 hover:text-indigo-600 transition-colors">
94
+ + 添加竞品列
95
+ </button>
96
+ <button @click="addRow" class="w-full py-2 px-3 border border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-indigo-500 hover:text-indigo-600 transition-colors">
97
+ + 添加功能行
98
+ </button>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- Canvas Area -->
104
+ <div class="lg:col-span-3 overflow-x-auto">
105
+ <div class="min-w-[600px] border rounded-xl overflow-hidden shadow-lg transition-all duration-300"
106
+ id="capture-area"
107
+ :class="themeClasses">
108
+
109
+ <!-- Table Header -->
110
+ <div class="grid" :style="{ gridTemplateColumns: gridTemplateCols }">
111
+ <!-- Top Left Empty -->
112
+ <div class="p-4 border-b border-r border-gray-200/20 flex items-center justify-center">
113
+ <span class="text-xs font-bold opacity-50 uppercase tracking-wider">Features</span>
114
+ </div>
115
+
116
+ <!-- Product Columns Headers -->
117
+ <div v-for="(col, index) in columns"
118
+ :key="col.id"
119
+ class="p-4 border-b border-gray-200/20 text-center relative group"
120
+ :class="{'bg-opacity-10': col.isPrimary}"
121
+ :style="{ backgroundColor: col.isPrimary ? primaryColor + '1A' : 'transparent' }"
122
+ >
123
+ <!-- Delete Button (Hover) -->
124
+ <button v-if="columns.length > 1"
125
+ @click="removeColumn(index)"
126
+ class="absolute -top-2 -right-2 bg-red-500 text-white w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 shadow-sm">
127
+ &times;
128
+ </button>
129
+
130
+ <div v-if="showLogo" class="w-10 h-10 mx-auto mb-2 rounded-full bg-gray-200/50 flex items-center justify-center overflow-hidden">
131
+ <i class="ph ph-image text-xl opacity-50"></i>
132
+ </div>
133
+
134
+ <h3 contenteditable="true"
135
+ @blur="updateColName(index, $event)"
136
+ class="font-bold text-lg outline-none border-b border-transparent focus:border-indigo-500 inline-block px-1"
137
+ :class="{'text-indigo-600': col.isPrimary}"
138
+ :style="{ color: col.isPrimary ? primaryColor : 'inherit' }"
139
+ >[[ col.name ]]</h3>
140
+
141
+ <div class="mt-1 flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
142
+ <button @click="setPrimary(index)"
143
+ class="text-xs px-2 py-0.5 rounded-full border border-current"
144
+ :class="col.isPrimary ? 'opacity-100' : 'opacity-40 hover:opacity-100'">
145
+ [[ col.isPrimary ? 'My Product' : 'Competitor' ]]
146
+ </button>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <!-- Table Rows -->
152
+ <div v-for="(row, rIndex) in rows" :key="row.id"
153
+ class="grid group hover:bg-black/5"
154
+ :style="{ gridTemplateColumns: gridTemplateCols }">
155
+
156
+ <!-- Feature Name -->
157
+ <div class="p-3 border-b border-r border-gray-200/20 flex items-center relative pl-6">
158
+ <button v-if="rows.length > 1"
159
+ @click="removeRow(rIndex)"
160
+ class="absolute left-1 top-1/2 -translate-y-1/2 text-red-400 opacity-0 group-hover:opacity-100 hover:text-red-600 p-1">
161
+ <i class="ph ph-trash"></i>
162
+ </button>
163
+ <span contenteditable="true"
164
+ @blur="updateRowName(rIndex, $event)"
165
+ class="font-medium outline-none border-b border-transparent focus:border-indigo-500 w-full"
166
+ >[[ row.name ]]</span>
167
+ </div>
168
+
169
+ <!-- Cells -->
170
+ <div v-for="(col, cIndex) in columns"
171
+ :key="col.id"
172
+ @click="cycleCellType(rIndex, col.id)"
173
+ class="p-3 border-b border-gray-200/20 flex items-center justify-center cursor-pointer hover:bg-black/5 transition-colors select-none relative"
174
+ :class="{'bg-opacity-5': col.isPrimary}"
175
+ :style="{ backgroundColor: col.isPrimary ? primaryColor + '0D' : 'transparent' }"
176
+ >
177
+ <div v-if="getCell(row.id, col.id).type === 'check'" class="text-2xl" :style="{ color: primaryColor }">
178
+ <i class="ph ph-check-circle-fill"></i>
179
+ </div>
180
+ <div v-else-if="getCell(row.id, col.id).type === 'cross'" class="text-2xl text-gray-300">
181
+ <i class="ph ph-x-circle"></i>
182
+ </div>
183
+ <div v-else-if="getCell(row.id, col.id).type === 'dash'" class="text-2xl text-gray-300">
184
+ <i class="ph ph-minus"></i>
185
+ </div>
186
+ <div v-else-if="getCell(row.id, col.id).type === 'text'" @click.stop class="w-full text-center">
187
+ <input type="text"
188
+ v-model="getCell(row.id, col.id).value"
189
+ class="bg-transparent text-center w-full outline-none text-sm"
190
+ placeholder="Text...">
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Footer / Branding -->
196
+ <div class="p-4 flex justify-between items-center opacity-60 text-xs">
197
+ <span>Generated by SaaS Comparison Studio</span>
198
+ <span>[[ currentDate ]]</span>
199
+ </div>
200
+ </div>
201
+
202
+ <p class="mt-4 text-center text-sm text-gray-500">
203
+ 💡 提示:点击单元格可切换状态 (对勾 -> 叉 -> 减号 -> 文字)
204
+ </p>
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ <script>
210
+ const { createApp, ref, computed, onMounted, watch } = Vue;
211
+
212
+ createApp({
213
+ compilerOptions: {
214
+ delimiters: ['[[', ']]']
215
+ },
216
+ setup() {
217
+ const currentTheme = ref('light');
218
+ const primaryColor = ref('#4F46E5'); // Indigo-600
219
+ const showLogo = ref(false);
220
+
221
+ const themes = [
222
+ { value: 'light', label: '简约白', bg: '#ffffff', text: '#111827' },
223
+ { value: 'dark', label: '深邃黑', bg: '#111827', text: '#ffffff' },
224
+ { value: 'warm', label: '暖色调', bg: '#FFF7ED', text: '#431407' }
225
+ ];
226
+
227
+ const colors = ['#4F46E5', '#10B981', '#EF4444', '#F59E0B', '#EC4899', '#3B82F6'];
228
+
229
+ const columns = ref([
230
+ { id: 'c1', name: 'Competitor A', isPrimary: false },
231
+ { id: 'c2', name: 'My Product', isPrimary: true },
232
+ { id: 'c3', name: 'Competitor B', isPrimary: false }
233
+ ]);
234
+
235
+ const rows = ref([
236
+ { id: 'r1', name: 'Unlimited Projects' },
237
+ { id: 'r2', name: '24/7 Support' },
238
+ { id: 'r3', name: 'Custom Domain' },
239
+ { id: 'r4', name: 'API Access' },
240
+ { id: 'r5', name: 'White Labeling' }
241
+ ]);
242
+
243
+ // Map of rowId -> { colId -> { type: 'check'|'cross'|'dash'|'text', value: '' } }
244
+ const cells = ref({});
245
+
246
+ // Initialize cells
247
+ const initCells = () => {
248
+ rows.value.forEach(row => {
249
+ if (!cells.value[row.id]) cells.value[row.id] = {};
250
+ columns.value.forEach(col => {
251
+ if (!cells.value[row.id][col.id]) {
252
+ // Default logic: Primary gets checks, others get mixed
253
+ const isGood = col.isPrimary || Math.random() > 0.5;
254
+ cells.value[row.id][col.id] = {
255
+ type: isGood ? 'check' : 'cross',
256
+ value: ''
257
+ };
258
+ }
259
+ });
260
+ });
261
+ };
262
+
263
+ const gridTemplateCols = computed(() => {
264
+ return `200px repeat(${columns.value.length}, minmax(140px, 1fr))`;
265
+ });
266
+
267
+ const themeClasses = computed(() => {
268
+ if (currentTheme.value === 'light') return 'bg-white text-gray-900 border-gray-200';
269
+ if (currentTheme.value === 'dark') return 'bg-gray-900 text-white border-gray-700';
270
+ if (currentTheme.value === 'warm') return 'bg-orange-50 text-orange-900 border-orange-200';
271
+ return '';
272
+ });
273
+
274
+ const currentDate = computed(() => {
275
+ return new Date().toLocaleDateString();
276
+ });
277
+
278
+ const getCell = (rowId, colId) => {
279
+ if (!cells.value[rowId]) cells.value[rowId] = {};
280
+ if (!cells.value[rowId][colId]) cells.value[rowId][colId] = { type: 'dash', value: '' };
281
+ return cells.value[rowId][colId];
282
+ };
283
+
284
+ const cycleCellType = (rowId, colId) => {
285
+ const cell = getCell(rowId, colId);
286
+ const types = ['check', 'cross', 'dash', 'text'];
287
+ const idx = types.indexOf(cell.type);
288
+ cell.type = types[(idx + 1) % types.length];
289
+ };
290
+
291
+ const addColumn = () => {
292
+ const newId = 'c' + Date.now();
293
+ columns.value.push({ id: newId, name: 'New Product', isPrimary: false });
294
+ rows.value.forEach(row => {
295
+ cells.value[row.id][newId] = { type: 'cross', value: '' };
296
+ });
297
+ };
298
+
299
+ const removeColumn = (index) => {
300
+ columns.value.splice(index, 1);
301
+ };
302
+
303
+ const addRow = () => {
304
+ const newId = 'r' + Date.now();
305
+ rows.value.push({ id: newId, name: 'New Feature' });
306
+ cells.value[newId] = {};
307
+ columns.value.forEach(col => {
308
+ cells.value[newId][col.id] = { type: 'check', value: '' };
309
+ });
310
+ };
311
+
312
+ const removeRow = (index) => {
313
+ const id = rows.value[index].id;
314
+ rows.value.splice(index, 1);
315
+ delete cells.value[id];
316
+ };
317
+
318
+ const updateColName = (index, event) => {
319
+ columns.value[index].name = event.target.innerText;
320
+ };
321
+
322
+ const updateRowName = (index, event) => {
323
+ rows.value[index].name = event.target.innerText;
324
+ };
325
+
326
+ const setPrimary = (index) => {
327
+ columns.value.forEach((c, i) => c.isPrimary = (i === index));
328
+ };
329
+
330
+ const exportImage = async () => {
331
+ const element = document.getElementById('capture-area');
332
+ const canvas = await html2canvas(element, {
333
+ scale: 2,
334
+ backgroundColor: null
335
+ });
336
+ const link = document.createElement('a');
337
+ link.download = `comparison-chart-${Date.now()}.png`;
338
+ link.href = canvas.toDataURL();
339
+ link.click();
340
+ };
341
+
342
+ const resetData = () => {
343
+ if(confirm('确定要重置所有数据吗?')) {
344
+ localStorage.removeItem('saas-comparison-data');
345
+ location.reload();
346
+ }
347
+ };
348
+
349
+ // Persistence
350
+ onMounted(() => {
351
+ const saved = localStorage.getItem('saas-comparison-data');
352
+ if (saved) {
353
+ try {
354
+ const data = JSON.parse(saved);
355
+ columns.value = data.columns;
356
+ rows.value = data.rows;
357
+ cells.value = data.cells;
358
+ currentTheme.value = data.theme;
359
+ primaryColor.value = data.primaryColor;
360
+ } catch(e) { console.error(e); initCells(); }
361
+ } else {
362
+ initCells();
363
+ }
364
+ });
365
+
366
+ watch([columns, rows, cells, currentTheme, primaryColor], () => {
367
+ localStorage.setItem('saas-comparison-data', JSON.stringify({
368
+ columns: columns.value,
369
+ rows: rows.value,
370
+ cells: cells.value,
371
+ theme: currentTheme.value,
372
+ primaryColor: primaryColor.value
373
+ }));
374
+ }, { deep: true });
375
+
376
+ return {
377
+ columns, rows, cells, currentTheme, themes, primaryColor, colors, showLogo,
378
+ getCell, cycleCellType, addColumn, removeColumn, addRow, removeRow,
379
+ updateColName, updateRowName, setPrimary, exportImage, resetData,
380
+ gridTemplateCols, themeClasses, currentDate
381
+ };
382
+ }
383
+ }).mount('#app');
384
+ </script>
385
+ </body>
386
+ </html>