duqing2026 commited on
Commit
812e9ed
·
1 Parent(s): 76257a0

Update: Enhance UI, add rich text features, default data, and preview mode

Browse files
Files changed (3) hide show
  1. README.md +22 -18
  2. app.py +8 -2
  3. templates/index.html +394 -231
README.md CHANGED
@@ -9,36 +9,40 @@ license: mit
9
  short_description: 小红书/IG 轮播图制作神器
10
  ---
11
 
12
- # 自媒体轮播图制作神器 (Carousel Maker Pro)
13
 
14
  这是一个专为小红书、Instagram 创作者设计的 **无缝轮播图制作工具**。你可以轻松制作跨页拼接的长图,添加跨页文字,并一键切片导出。
15
 
16
- ## 核心功能
17
 
18
- * **无缝拼接背景**:上传一张长图或背景图,自动填充到所有页面,实现完美的滑动视觉效果。
19
- * **跨页文字排版**:支持在页面之间自由拖拽文字,文字可以横跨两张图片(小红书流行的“破格”设计)。
20
  * **多尺寸支持**:
21
  * **3:4 (1080x1350)**:小红书/Instagram 标准尺寸。
22
  * **1:1 (1080x1080)**:朋友圈/INS 正方形。
23
  * **9:16 (1080x1920)**:Story/抖音图文。
24
- * **一键切片导出**:前端自动高清渲染(基于 HTML2Canvas),一键生成分片后的 ZIP 包。
25
- * **自动页码**:一键添加 "1/4", "2/4" 等样式统一的页码。
26
- * **隐私安全**:纯前端渲染,图片不上传服务器,保护你的素材安全。
27
 
28
- ## 为什么做这个?
 
 
 
 
29
 
30
- 市面上的切图工具(Grid Splitter)大多只能切图,无法在切图前进行整体排版(加字、加遮罩)。而专业设计软件(PS/Figma)对普通创作者门槛较高。本项目填补了这一空白,让普通人也能做出“设计感”极强的连页轮播图。
 
 
 
31
 
32
- ## 技术栈
33
 
34
- * **Backend**: Flask (Python) - 提供静态资源服务。
35
- * **Frontend**: Vue 3 (Composition API) + Tailwind CSS
36
- * **Core**:
37
- * `html2canvas`: 负责将 DOM 渲染为图片。
38
- * `JSZip`: 负责前端打包下载。
39
- * **Deployment**: Docker (User 1000) for Hugging Face Spaces.
40
 
41
- ## 本地运行
42
 
43
  1. 安装依赖:
44
  ```bash
@@ -50,7 +54,7 @@ short_description: 小红书/IG 轮播图制作神器
50
  ```
51
  3. 访问: `http://localhost:7860`
52
 
53
- ## 部署到 Hugging Face Spaces
54
 
55
  本项目包含 Dockerfile,可直接部署。
56
  1. 新建 Space,SDK 选择 Docker。
 
9
  short_description: 小红书/IG 轮播图制作神器
10
  ---
11
 
12
+ # 自媒体轮播图制作神器 (Carousel Maker Pro) v1.1
13
 
14
  这是一个专为小红书、Instagram 创作者设计的 **无缝轮播图制作工具**。你可以轻松制作跨页拼接的长图,添加跨页文字,并一键切片导出。
15
 
16
+ ## 核心功能
17
 
18
+ ### 🎨 强大的画布
19
+ * **无缝拼接背景**:上传一张长图或背景图,自动填充到 3-10 页轮播中,支持 Cover/Contain 模式。
20
  * **多尺寸支持**:
21
  * **3:4 (1080x1350)**:小红书/Instagram 标准尺寸。
22
  * **1:1 (1080x1080)**:朋友圈/INS 正方形。
23
  * **9:16 (1080x1920)**:Story/抖音图文。
24
+ * **可视化辅助**:清晰的页面分割线、页码标记,所见即所得。
 
 
25
 
26
+ ### ✍️ 丰富的文字编辑
27
+ * **跨页排版**:支持文字横跨两张图片(小红书爆款设计)。
28
+ * **多样字体**:内置标准黑体、毛笔书法(Ma Shan Zheng)、快乐体(ZCOOL KuaiLe)。
29
+ * **样式定制**:支持文字颜色、背景色、字号、加粗、阴影、对齐方式(左/中/右)。
30
+ * **自动页码**:一键添加 "1/4", "2/4" 等样式统一的页码。
31
 
32
+ ### 🚀 高效导出
33
+ * **切片预览**:导出前可预览切片效果,模拟真实滑动体验。
34
+ * **一键打包**:前端自动高清渲染(基于 HTML2Canvas),一键生成分片后的 ZIP 包。
35
+ * **隐私安全**:纯前端渲染,图片不上传服务器,保护你的素材安全。
36
 
37
+ ## 🛠️ 技术栈
38
 
39
+ * **Backend**: Flask (Python)
40
+ * **Frontend**: Vue 3 (Composition API) + Tailwind CSS
41
+ * **Core**: `html2canvas`, `JSZip`, `FileSaver.js`
42
+ * **Fonts**: Google Fonts
43
+ * **Deployment**: Docker (User 1000) for Hugging Face Spaces
 
44
 
45
+ ## 📦 本地运行
46
 
47
  1. 安装依赖:
48
  ```bash
 
54
  ```
55
  3. 访问: `http://localhost:7860`
56
 
57
+ ## ☁️ 部署到 Hugging Face Spaces
58
 
59
  本项目包含 Dockerfile,可直接部署。
60
  1. 新建 Space,SDK 选择 Docker。
app.py CHANGED
@@ -1,5 +1,5 @@
1
  import os
2
- from flask import Flask, render_template, send_from_directory
3
 
4
  app = Flask(__name__, static_folder='static', template_folder='templates')
5
 
@@ -7,9 +7,15 @@ app = Flask(__name__, static_folder='static', template_folder='templates')
7
  def index():
8
  return render_template('index.html')
9
 
 
 
 
 
10
  @app.route('/static/<path:path>')
11
  def serve_static(path):
12
  return send_from_directory('static', path)
13
 
14
  if __name__ == '__main__':
15
- app.run(host='0.0.0.0', port=7860)
 
 
 
1
  import os
2
+ from flask import Flask, render_template, send_from_directory, jsonify
3
 
4
  app = Flask(__name__, static_folder='static', template_folder='templates')
5
 
 
7
  def index():
8
  return render_template('index.html')
9
 
10
+ @app.route('/health')
11
+ def health():
12
+ return jsonify({"status": "ok", "service": "carousel-maker-pro"})
13
+
14
  @app.route('/static/<path:path>')
15
  def serve_static(path):
16
  return send_from_directory('static', path)
17
 
18
  if __name__ == '__main__':
19
+ # Use 7860 for Hugging Face Spaces
20
+ port = int(os.environ.get('PORT', 7860))
21
+ app.run(host='0.0.0.0', port=port)
templates/index.html CHANGED
@@ -3,164 +3,262 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>小红书轮播图制作神器 | Carousel Maker Pro</title>
7
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
  <script src="https://cdn.tailwindcss.com"></script>
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
11
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
12
- <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet">
 
13
  <style>
14
  body { font-family: 'Noto Sans SC', sans-serif; }
 
 
15
  .slide-grid {
16
- background-image:
17
- linear-gradient(to right, rgba(255,255,255,0.3) 1px, transparent 1px);
18
  background-size: var(--slide-width) 100%;
19
  }
 
 
20
  .text-element {
21
  cursor: move;
22
  user-select: none;
 
23
  }
24
  .text-element:hover {
25
- outline: 2px dashed #3b82f6;
26
  }
27
  .text-element.selected {
28
  outline: 2px solid #2563eb;
 
29
  }
 
 
30
  [contenteditable]:empty:before {
31
  content: attr(placeholder);
32
- color: #aaa;
33
  }
34
- /* Hide scrollbar for workspace */
35
- .workspace-scroll::-webkit-scrollbar {
36
- height: 8px;
 
 
 
 
 
37
  }
38
- .workspace-scroll::-webkit-scrollbar-thumb {
39
  background: #cbd5e1;
40
- border-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }
42
  </style>
43
  </head>
44
- <body class="bg-gray-100 h-screen flex flex-col overflow-hidden">
45
  <div id="app" class="flex flex-col h-full">
46
  <!-- Header -->
47
- <header class="bg-white shadow-sm z-10 p-4 flex justify-between items-center">
48
  <div class="flex items-center gap-3">
49
- <div class="bg-red-500 text-white p-2 rounded-lg font-bold text-xl">
50
- CM
 
 
 
 
 
 
51
  </div>
52
- <h1 class="text-xl font-bold text-gray-800">Carousel Maker Pro <span class="text-xs bg-red-100 text-red-600 px-2 py-1 rounded ml-2">小红书神器</span></h1>
53
  </div>
54
- <div class="flex gap-4">
55
- <button @click="exportImages" class="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-full font-medium flex items-center gap-2 transition-colors">
56
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
57
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
 
58
  </svg>
59
- 导出 ZIP
 
 
 
 
60
  </button>
61
  </div>
62
  </header>
63
 
64
  <div class="flex flex-1 overflow-hidden">
65
- <!-- Sidebar Controls -->
66
- <aside class="w-80 bg-white border-r p-6 flex flex-col gap-6 overflow-y-auto">
67
-
68
- <!-- Layout Settings -->
69
- <div class="space-y-3">
70
- <h3 class="font-bold text-gray-700 flex items-center gap-2">
71
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
72
- 布局设置
73
- </h3>
74
- <div class="grid grid-cols-2 gap-2">
75
- <div>
76
- <label class="text-xs text-gray-500 mb-1 block">页数</label>
77
- <input type="number" v-model="slideCount" min="2" max="10" class="w-full border rounded px-3 py-2 text-sm">
78
- </div>
79
- <div>
80
- <label class="text-xs text-gray-500 mb-1 block">比例</label>
81
- <select v-model="aspectRatio" class="w-full border rounded px-3 py-2 text-sm">
82
- <option value="3:4">3:4 (小红书)</option>
83
- <option value="1:1">1:1 (INS/朋友圈)</option>
84
- <option value="9:16">9:16 (Story)</option>
85
- </select>
 
 
 
86
  </div>
87
- </div>
88
- </div>
89
 
90
- <!-- Background -->
91
- <div class="space-y-3">
92
- <h3 class="font-bold text-gray-700 flex items-center gap-2">
93
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
94
- 背景图片
95
- </h3>
96
- <div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:bg-gray-50 transition-colors cursor-pointer relative">
97
- <input type="file" @change="handleImageUpload" class="absolute inset-0 opacity-0 cursor-pointer" accept="image/*">
98
- <p class="text-sm text-gray-500" v-if="!bgImage">点击上传背景图</p>
99
- <div v-else class="relative h-20 w-full">
100
- <img :src="bgImage" class="h-full w-full object-cover rounded">
101
- <button @click.stop="bgImage = null" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 text-xs shadow">✕</button>
 
 
 
 
 
 
 
 
 
102
  </div>
103
- </div>
104
- <div class="flex gap-2 text-xs">
105
- <button @click="fitMode = 'cover'" :class="{'bg-blue-100 text-blue-600': fitMode==='cover'}" class="flex-1 border rounded py-1 hover:bg-gray-50">填满 Cover</button>
106
- <button @click="fitMode = 'contain'" :class="{'bg-blue-100 text-blue-600': fitMode==='contain'}" class="flex-1 border rounded py-1 hover:bg-gray-50">适应 Contain</button>
107
- </div>
108
- </div>
109
-
110
- <!-- Text Tools -->
111
- <div class="space-y-3">
112
- <h3 class="font-bold text-gray-700 flex items-center gap-2">
113
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
114
- 文字组件
115
- </h3>
116
- <div class="grid grid-cols-2 gap-2">
117
- <button @click="addText('标题', 48, 700)" class="border rounded p-2 text-center hover:bg-gray-50 text-sm font-bold">
118
- + 大标题
119
- </button>
120
- <button @click="addText('正文内容', 24, 400)" class="border rounded p-2 text-center hover:bg-gray-50 text-sm">
121
- + 正文
122
- </button>
123
- <button @click="addNumbering" class="border rounded p-2 text-center hover:bg-gray-50 text-sm col-span-2">
124
- + 自动页码 (1/{{slideCount}})
125
- </button>
126
- </div>
127
- </div>
128
-
129
- <!-- Selection Settings -->
130
- <div v-if="selectedElement" class="border-t pt-4 space-y-3 animate-fade-in">
131
- <h3 class="font-bold text-gray-700 text-sm">选中样式</h3>
132
- <div class="grid grid-cols-2 gap-2 text-sm">
133
- <div>
134
- <label class="block text-gray-500 text-xs mb-1">颜色</label>
135
- <input type="color" v-model="selectedElement.color" class="w-full h-8 border rounded">
136
  </div>
137
- <div>
138
- <label class="block text-gray-500 text-xs mb-1">大小</label>
139
- <input type="number" v-model="selectedElement.fontSize" class="w-full border rounded px-2 py-1">
 
 
 
 
 
 
 
140
  </div>
141
- <div class="col-span-2">
142
- <label class="block text-gray-500 text-xs mb-1">背景色</label>
143
- <div class="flex gap-2">
144
- <input type="checkbox" v-model="selectedElement.hasBg">
145
- <input type="color" v-model="selectedElement.bgColor" :disabled="!selectedElement.hasBg" class="flex-1 h-6 border rounded">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </div>
147
  </div>
148
- </div>
149
- <button @click="deleteSelected" class="w-full bg-red-100 text-red-600 py-2 rounded text-sm hover:bg-red-200">删除选中元素</button>
150
  </div>
151
 
152
- <div class="mt-auto text-xs text-gray-400">
153
- <p>拖拽文字可移动位���</p>
154
- <p>点击文字进行编辑</p>
155
  </div>
156
  </aside>
157
 
158
  <!-- Main Workspace -->
159
- <main class="flex-1 bg-gray-200 overflow-auto workspace-scroll relative flex items-center justify-center p-10">
160
 
161
  <!-- The Canvas Area -->
162
  <div id="canvas-container"
163
- class="bg-white shadow-2xl relative transition-all duration-300"
164
  :style="containerStyle"
165
  @click.self="selectedId = null">
166
 
@@ -169,39 +267,36 @@
169
  <img v-if="bgImage" :src="bgImage"
170
  class="w-full h-full"
171
  :class="fitMode === 'cover' ? 'object-cover' : 'object-contain'">
172
- <div v-else class="w-full h-full bg-gradient-to-br from-indigo-50 to-pink-50 flex items-center justify-center text-gray-300 font-bold text-4xl">
173
- DROP IMAGE HERE
 
 
 
 
 
174
  </div>
175
  </div>
176
 
177
- <!-- Grid Lines Layer -->
178
- <div class="absolute inset-0 slide-grid pointer-events-none border border-gray-300 z-10"
179
  :style="{'--slide-width': singleSlideWidth + 'px'}">
180
- <!-- Slide Numbers -->
181
- <div class="absolute top-0 left-0 w-full h-full flex">
182
- <div v-for="n in slideCount" :key="n" class="flex-1 border-r border-dashed border-gray-400/30 flex justify-center pt-2 relative">
183
- <span class="bg-black/20 text-white text-[10px] px-2 rounded-full backdrop-blur-sm h-fit">
184
- P{{ n }}
185
- </span>
186
  </div>
187
  </div>
188
  </div>
189
 
190
- <!-- Content Layer -->
191
  <div class="absolute inset-0 z-20 overflow-hidden">
192
  <div v-for="el in elements"
193
  :key="el.id"
194
  class="text-element absolute whitespace-nowrap p-2 rounded"
195
  :class="{ 'selected': selectedId === el.id }"
196
- :style="{
197
- left: el.x + 'px',
198
- top: el.y + 'px',
199
- color: el.color,
200
- fontSize: el.fontSize + 'px',
201
- fontWeight: el.fontWeight,
202
- backgroundColor: el.hasBg ? el.bgColor : 'transparent',
203
- transform: 'translate(-50%, -50%)'
204
- }"
205
  @mousedown="startDrag($event, el)"
206
  @click.stop="selectElement(el)">
207
 
@@ -209,6 +304,7 @@
209
  @input="updateText(el, $event)"
210
  @blur="cleanupText(el)"
211
  class="outline-none min-w-[20px]"
 
212
  v-html="el.text">
213
  </div>
214
  </div>
@@ -217,14 +313,35 @@
217
  </div>
218
  </main>
219
  </div>
220
-
221
- <!-- Processing Modal -->
222
- <div v-if="isProcessing" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm">
223
- <div class="bg-white rounded-xl p-8 flex flex-col items-center gap-4">
224
- <div class="animate-spin rounded-full h-10 w-10 border-b-2 border-red-600"></div>
225
- <p class="text-gray-600 font-medium">正在生成高清切片...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  </div>
227
  </div>
 
 
 
 
 
 
 
228
  </div>
229
 
230
  <script>
@@ -232,6 +349,7 @@
232
 
233
  createApp({
234
  setup() {
 
235
  const slideCount = ref(4);
236
  const aspectRatio = ref('3:4');
237
  const bgImage = ref(null);
@@ -239,10 +357,11 @@
239
  const elements = ref([]);
240
  const selectedId = ref(null);
241
  const isProcessing = ref(false);
 
 
242
 
243
- // Constants for rendering resolution
244
- // Base height 1350 (standard 4:5), width 1080
245
- const BASE_HEIGHT = 600; // Display height (scaled down for viewing)
246
 
247
  const ratioMap = {
248
  '3:4': 3/4,
@@ -250,24 +369,55 @@
250
  '9:16': 9/16
251
  };
252
 
253
- const singleSlideWidth = computed(() => {
254
- return BASE_HEIGHT * ratioMap[aspectRatio.value];
255
- });
 
 
 
 
 
256
 
257
- const totalWidth = computed(() => {
258
- return singleSlideWidth.value * slideCount.value;
259
- });
260
 
261
- const containerStyle = computed(() => {
262
- return {
263
- width: totalWidth.value + 'px',
264
- height: BASE_HEIGHT + 'px'
265
- };
266
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- const selectedElement = computed(() => {
269
- return elements.value.find(e => e.id === selectedId.value);
270
- });
271
 
272
  const handleImageUpload = (e) => {
273
  const file = e.target.files[0];
@@ -279,48 +429,49 @@
279
 
280
  const addText = (text, size, weight) => {
281
  const id = Date.now();
 
282
  elements.value.push({
283
  id,
284
  text,
285
- x: totalWidth.value / 2,
286
  y: BASE_HEIGHT / 2,
287
  fontSize: size,
288
  fontWeight: weight,
289
  color: '#000000',
 
 
290
  hasBg: false,
291
- bgColor: '#ffffff'
 
 
292
  });
293
  selectedId.value = id;
294
  };
295
 
296
  const addNumbering = () => {
297
- // Remove existing numbering
298
  elements.value = elements.value.filter(e => !e.isPageNumber);
299
-
300
  for(let i=0; i < slideCount.value; i++) {
301
  elements.value.push({
302
- id: Date.now() + i,
303
- text: `${i+1}/${slideCount.value}`,
304
  x: (i * singleSlideWidth.value) + (singleSlideWidth.value / 2),
305
- y: BASE_HEIGHT - 30,
306
- fontSize: 16,
307
  fontWeight: 400,
308
- color: '#ffffff',
309
- hasBg: true,
 
 
310
  bgColor: '#000000',
 
 
311
  isPageNumber: true
312
  });
313
  }
314
  };
315
 
316
- const selectElement = (el) => {
317
- selectedId.value = el.id;
318
- };
319
-
320
- const updateText = (el, event) => {
321
- el.text = event.target.innerHTML;
322
- };
323
-
324
  const deleteSelected = () => {
325
  if (selectedId.value) {
326
  elements.value = elements.value.filter(e => e.id !== selectedId.value);
@@ -328,36 +479,41 @@
328
  }
329
  };
330
 
331
- // Dragging Logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  let dragOffset = { x: 0, y: 0 };
333
  let isDragging = false;
334
  let activeEl = null;
335
 
336
  const startDrag = (e, el) => {
337
- if (e.target.isContentEditable) return; // Don't drag if editing text
338
  isDragging = true;
339
  activeEl = el;
340
- dragOffset.x = e.clientX - el.x; // Simplified, assuming transform translate(-50%, -50%) logic matches visual
341
- // Actually, with translate(-50%, -50%), el.x is center.
342
- // Mouse is at clientX.
343
- // Let's just track delta.
344
- dragOffset.x = e.clientX;
345
- dragOffset.y = e.clientY;
346
-
347
  document.addEventListener('mousemove', onDrag);
348
  document.addEventListener('mouseup', stopDrag);
349
  };
350
 
351
  const onDrag = (e) => {
352
  if (!isDragging || !activeEl) return;
353
- const dx = e.clientX - dragOffset.x;
354
- const dy = e.clientY - dragOffset.y;
355
-
356
- activeEl.x += dx;
357
- activeEl.y += dy;
358
-
359
- dragOffset.x = e.clientX;
360
- dragOffset.y = e.clientY;
361
  };
362
 
363
  const stopDrag = () => {
@@ -367,79 +523,86 @@
367
  document.removeEventListener('mouseup', stopDrag);
368
  };
369
 
370
- // Export Logic
371
- const exportImages = async () => {
372
- selectedId.value = null; // Deselect to remove borders
373
  isProcessing.value = true;
374
-
375
- await new Promise(r => setTimeout(r, 100)); // UI update
376
 
 
 
 
377
  try {
378
- const container = document.getElementById('canvas-container');
379
-
380
- // High quality capture
381
- // We scale up the canvas to match 1080px width per slide
382
- const scaleFactor = 1080 / singleSlideWidth.value;
383
-
384
  const canvas = await html2canvas(container, {
385
  scale: scaleFactor,
386
  useCORS: true,
387
- backgroundColor: null
 
388
  });
389
 
390
- const zip = new JSZip();
391
- const slideW = 1080; // Target width
392
  const slideH = slideW / ratioMap[aspectRatio.value];
393
 
394
- // Slice
395
  for (let i = 0; i < slideCount.value; i++) {
396
  const sliceCanvas = document.createElement('canvas');
397
  sliceCanvas.width = slideW;
398
  sliceCanvas.height = slideH;
399
  const ctx = sliceCanvas.getContext('2d');
 
400
 
401
- // Draw portion
402
- // Source X = i * slideW
403
- ctx.drawImage(canvas,
404
- i * slideW, 0, slideW, slideH,
405
- 0, 0, slideW, slideH
406
- );
407
-
408
- const blob = await new Promise(resolve => sliceCanvas.toBlob(resolve, 'image/png'));
409
- zip.file(`slide_${i+1}.png`, blob);
410
  }
411
-
412
- const content = await zip.generateAsync({type:"blob"});
413
- saveAs(content, "carousel_slides.zip");
414
-
415
  } catch (e) {
416
- alert('导出失败: ' + e.message);
417
  console.error(e);
 
 
418
  } finally {
419
  isProcessing.value = false;
420
  }
421
  };
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  return {
424
- slideCount,
425
- aspectRatio,
426
- bgImage,
427
- fitMode,
428
- elements,
429
- selectedElement,
430
- selectedId,
431
- isProcessing,
432
- singleSlideWidth,
433
- totalWidth,
434
- containerStyle,
435
- handleImageUpload,
436
- addText,
437
- addNumbering,
438
- selectElement,
439
- updateText,
440
- deleteSelected,
441
- startDrag,
442
- exportImages
443
  };
444
  }
445
  }).mount('#app');
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Carousel Maker Pro - 小红书轮播图神器</title>
7
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
  <script src="https://cdn.tailwindcss.com"></script>
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
11
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
12
+ <!-- Google Fonts -->
13
+ <link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Sans+SC:wght@400;700;900&family=ZCOOL+KuaiLe&display=swap" rel="stylesheet">
14
  <style>
15
  body { font-family: 'Noto Sans SC', sans-serif; }
16
+
17
+ /* Canvas Grid Pattern */
18
  .slide-grid {
19
+ background-image: linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px);
 
20
  background-size: var(--slide-width) 100%;
21
  }
22
+
23
+ /* Element Interaction */
24
  .text-element {
25
  cursor: move;
26
  user-select: none;
27
+ transition: outline 0.1s;
28
  }
29
  .text-element:hover {
30
+ outline: 1px dashed #3b82f6;
31
  }
32
  .text-element.selected {
33
  outline: 2px solid #2563eb;
34
+ z-index: 50; /* Bring selected to front visually */
35
  }
36
+
37
+ /* Content Editable Placeholder */
38
  [contenteditable]:empty:before {
39
  content: attr(placeholder);
40
+ color: rgba(0,0,0,0.3);
41
  }
42
+
43
+ /* Scrollbar Styling */
44
+ .custom-scroll::-webkit-scrollbar {
45
+ width: 6px;
46
+ height: 6px;
47
+ }
48
+ .custom-scroll::-webkit-scrollbar-track {
49
+ background: #f1f5f9;
50
  }
51
+ .custom-scroll::-webkit-scrollbar-thumb {
52
  background: #cbd5e1;
53
+ border-radius: 3px;
54
+ }
55
+ .custom-scroll::-webkit-scrollbar-thumb:hover {
56
+ background: #94a3b8;
57
+ }
58
+
59
+ /* Loading Spinner */
60
+ .loader {
61
+ border: 3px solid #f3f3f3;
62
+ border-radius: 50%;
63
+ border-top: 3px solid #ef4444;
64
+ width: 24px;
65
+ height: 24px;
66
+ animation: spin 1s linear infinite;
67
+ }
68
+ @keyframes spin {
69
+ 0% { transform: rotate(0deg); }
70
+ 100% { transform: rotate(360deg); }
71
  }
72
  </style>
73
  </head>
74
+ <body class="bg-gray-100 h-screen flex flex-col overflow-hidden text-slate-800">
75
  <div id="app" class="flex flex-col h-full">
76
  <!-- Header -->
77
+ <header class="bg-white border-b z-20 px-6 py-3 flex justify-between items-center shadow-sm">
78
  <div class="flex items-center gap-3">
79
+ <div class="bg-gradient-to-br from-red-500 to-pink-500 text-white p-2 rounded-lg shadow-md">
80
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
81
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
82
+ </svg>
83
+ </div>
84
+ <div>
85
+ <h1 class="text-xl font-black tracking-tight text-gray-800">Carousel Maker <span class="text-red-500">Pro</span></h1>
86
+ <p class="text-xs text-gray-400">小红书/IG 无缝轮播图设计工具</p>
87
  </div>
 
88
  </div>
89
+ <div class="flex gap-3">
90
+ <button @click="showPreview" class="px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium text-sm transition flex items-center gap-2">
91
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
94
  </svg>
95
+ 切片预览
96
+ </button>
97
+ <button @click="exportImages" :disabled="isProcessing" class="px-6 py-2 bg-gray-900 hover:bg-black text-white rounded-lg font-medium text-sm transition flex items-center gap-2 shadow-lg hover:shadow-xl disabled:opacity-50">
98
+ <div v-if="isProcessing" class="loader border-t-white w-4 h-4"></div>
99
+ <span v-else>导出 ZIP</span>
100
  </button>
101
  </div>
102
  </header>
103
 
104
  <div class="flex flex-1 overflow-hidden">
105
+ <!-- Left Sidebar: Settings -->
106
+ <aside class="w-80 bg-white border-r flex flex-col overflow-hidden z-10">
107
+ <div class="p-5 overflow-y-auto custom-scroll space-y-6">
108
+
109
+ <!-- 1. Layout -->
110
+ <section>
111
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">画布设置</h3>
112
+ <div class="grid grid-cols-2 gap-3">
113
+ <div>
114
+ <label class="text-xs text-gray-500 mb-1 block">页数 (Slides)</label>
115
+ <div class="flex items-center border rounded-md bg-gray-50">
116
+ <button @click="slideCount = Math.max(2, slideCount-1)" class="px-3 py-1 hover:bg-gray-200 text-gray-600">-</button>
117
+ <input type="number" v-model="slideCount" readonly class="w-full bg-transparent text-center text-sm font-medium focus:outline-none">
118
+ <button @click="slideCount = Math.min(10, slideCount+1)" class="px-3 py-1 hover:bg-gray-200 text-gray-600">+</button>
119
+ </div>
120
+ </div>
121
+ <div>
122
+ <label class="text-xs text-gray-500 mb-1 block">比例 (Ratio)</label>
123
+ <select v-model="aspectRatio" class="w-full border rounded-md px-2 py-1.5 text-sm bg-white focus:ring-2 focus:ring-red-500 outline-none">
124
+ <option value="3:4">3:4 (小红书)</option>
125
+ <option value="1:1">1:1 (INS)</option>
126
+ <option value="9:16">9:16 (Story)</option>
127
+ </select>
128
+ </div>
129
  </div>
130
+ </section>
 
131
 
132
+ <!-- 2. Background -->
133
+ <section>
134
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">背景图片</h3>
135
+ <div class="border-2 border-dashed border-gray-200 rounded-xl p-4 text-center hover:border-red-400 hover:bg-red-50 transition cursor-pointer relative group">
136
+ <input type="file" @change="handleImageUpload" class="absolute inset-0 opacity-0 cursor-pointer z-10" accept="image/*">
137
+
138
+ <div v-if="!bgImage" class="py-2">
139
+ <div class="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 mb-2 group-hover:bg-white group-hover:text-red-500">
140
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
141
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
142
+ </svg>
143
+ </div>
144
+ <p class="text-xs text-gray-500 font-medium">点击或拖拽上传背景</p>
145
+ </div>
146
+
147
+ <div v-else class="relative h-24 w-full rounded-lg overflow-hidden border">
148
+ <img :src="bgImage" class="w-full h-full object-cover">
149
+ <div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
150
+ <span class="text-white text-xs font-bold">更换图片</span>
151
+ </div>
152
+ </div>
153
  </div>
154
+
155
+ <div v-if="bgImage" class="mt-3 grid grid-cols-2 gap-2">
156
+ <button @click="fitMode = 'cover'" :class="fitMode==='cover' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-white text-gray-600 border-gray-200'" class="border rounded px-3 py-1.5 text-xs font-medium transition">填满 (Cover)</button>
157
+ <button @click="fitMode = 'contain'" :class="fitMode==='contain' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-white text-gray-600 border-gray-200'" class="border rounded px-3 py-1.5 text-xs font-medium transition">适应 (Contain)</button>
158
+ </div>
159
+ </section>
160
+
161
+ <!-- 3. Add Elements -->
162
+ <section>
163
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">添加组件</h3>
164
+ <div class="grid grid-cols-2 gap-2">
165
+ <button @click="addText('主标题', 56, 900)" class="flex items-center justify-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:shadow-md hover:border-red-300 transition group">
166
+ <span class="text-xl font-black text-gray-800 group-hover:text-red-600">T</span>
167
+ <span class="text-xs font-medium text-gray-600">大标题</span>
168
+ </button>
169
+ <button @click="addText('正文内容', 28, 400)" class="flex items-center justify-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:shadow-md hover:border-red-300 transition group">
170
+ <span class="text-sm font-normal text-gray-800 group-hover:text-red-600">t</span>
171
+ <span class="text-xs font-medium text-gray-600">正文</span>
172
+ </button>
173
+ <button @click="addNumbering" class="col-span-2 flex items-center justify-center gap-2 p-2 bg-indigo-50 border border-indigo-100 rounded-lg hover:bg-indigo-100 text-indigo-700 transition">
174
+ <span class="text-xs font-bold">#</span>
175
+ <span class="text-xs font-medium">自动页码 (1/{{slideCount}})</span>
176
+ </button>
 
 
 
 
 
 
 
 
 
 
177
  </div>
178
+ </section>
179
+
180
+ <!-- 4. Element Style (Conditional) -->
181
+ <section v-if="selectedElement" class="bg-gray-50 -mx-5 px-5 py-4 border-t border-b animate-fade-in">
182
+ <div class="flex justify-between items-center mb-3">
183
+ <h3 class="text-xs font-bold text-gray-800">样式编辑</h3>
184
+ <button @click="deleteSelected" class="text-red-500 hover:text-red-700 text-xs font-medium flex items-center gap-1">
185
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /></svg>
186
+ 删除
187
+ </button>
188
  </div>
189
+
190
+ <div class="space-y-3">
191
+ <!-- Font & Size -->
192
+ <div class="grid grid-cols-2 gap-2">
193
+ <div>
194
+ <label class="text-[10px] text-gray-400 mb-1 block">字体</label>
195
+ <select v-model="selectedElement.fontFamily" class="w-full text-xs border rounded p-1.5">
196
+ <option value="'Noto Sans SC', sans-serif">标准黑体</option>
197
+ <option value="'Ma Shan Zheng', cursive">毛笔书法</option>
198
+ <option value="'ZCOOL KuaiLe', cursive">快乐体</option>
199
+ </select>
200
+ </div>
201
+ <div>
202
+ <label class="text-[10px] text-gray-400 mb-1 block">字号 (px)</label>
203
+ <input type="number" v-model="selectedElement.fontSize" class="w-full text-xs border rounded p-1.5">
204
+ </div>
205
+ </div>
206
+
207
+ <!-- Color & Bg -->
208
+ <div class="grid grid-cols-2 gap-2">
209
+ <div>
210
+ <label class="text-[10px] text-gray-400 mb-1 block">文字颜色</label>
211
+ <div class="flex items-center gap-2 border rounded p-1 bg-white">
212
+ <input type="color" v-model="selectedElement.color" class="w-6 h-6 border-none rounded cursor-pointer">
213
+ <span class="text-xs text-gray-500">{{selectedElement.color}}</span>
214
+ </div>
215
+ </div>
216
+ <div>
217
+ <label class="text-[10px] text-gray-400 mb-1 block">背景颜色</label>
218
+ <div class="flex items-center gap-2 border rounded p-1 bg-white">
219
+ <input type="checkbox" v-model="selectedElement.hasBg" class="rounded text-red-500 focus:ring-red-500">
220
+ <input type="color" v-model="selectedElement.bgColor" :disabled="!selectedElement.hasBg" class="w-6 h-6 border-none rounded cursor-pointer disabled:opacity-30">
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ <!-- Alignment -->
226
+ <div>
227
+ <label class="text-[10px] text-gray-400 mb-1 block">对齐方式</label>
228
+ <div class="flex border rounded overflow-hidden bg-white">
229
+ <button @click="selectedElement.textAlign = 'left'" :class="{'bg-gray-200': selectedElement.textAlign === 'left'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h16"></path></svg></button>
230
+ <button @click="selectedElement.textAlign = 'center'" :class="{'bg-gray-200': selectedElement.textAlign === 'center'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button>
231
+ <button @click="selectedElement.textAlign = 'right'" :class="{'bg-gray-200': selectedElement.textAlign === 'right'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M10 12h10M4 18h16"></path></svg></button>
232
+ </div>
233
+ </div>
234
+
235
+ <!-- Effects -->
236
+ <div class="flex items-center gap-2">
237
+ <label class="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
238
+ <input type="checkbox" v-model="selectedElement.hasShadow" class="rounded text-red-500">
239
+ 文字阴影
240
+ </label>
241
+ <label class="flex items-center gap-2 text-xs text-gray-600 cursor-pointer ml-4">
242
+ <input type="checkbox" v-model="selectedElement.isBold" class="rounded text-red-500">
243
+ 加粗
244
+ </label>
245
  </div>
246
  </div>
247
+ </section>
 
248
  </div>
249
 
250
+ <!-- Footer Info -->
251
+ <div class="p-4 border-t bg-gray-50 text-[10px] text-gray-400 text-center">
252
+ Carousel Maker Pro v1.1
253
  </div>
254
  </aside>
255
 
256
  <!-- Main Workspace -->
257
+ <main class="flex-1 bg-gray-200 overflow-auto custom-scroll relative flex items-center justify-center p-12" @mousedown.self="selectedId = null">
258
 
259
  <!-- The Canvas Area -->
260
  <div id="canvas-container"
261
+ class="bg-white shadow-2xl relative transition-all duration-300 select-none"
262
  :style="containerStyle"
263
  @click.self="selectedId = null">
264
 
 
267
  <img v-if="bgImage" :src="bgImage"
268
  class="w-full h-full"
269
  :class="fitMode === 'cover' ? 'object-cover' : 'object-contain'">
270
+ <div v-else class="w-full h-full bg-gradient-to-br from-indigo-50 to-pink-50 flex items-center justify-center">
271
+ <div class="text-center opacity-30">
272
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
273
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
274
+ </svg>
275
+ <p class="font-bold text-2xl">Drop Background Here</p>
276
+ </div>
277
  </div>
278
  </div>
279
 
280
+ <!-- Grid & Guidelines Layer -->
281
+ <div class="absolute inset-0 slide-grid pointer-events-none z-10"
282
  :style="{'--slide-width': singleSlideWidth + 'px'}">
283
+ <!-- Page Labels -->
284
+ <div class="absolute -top-6 left-0 w-full flex text-xs text-gray-500 font-mono">
285
+ <div v-for="n in slideCount" :key="n" class="flex-1 text-center relative">
286
+ <span class="bg-gray-200 px-2 py-0.5 rounded-full">Page {{ n }}</span>
287
+ <!-- Vertical Dashed Line Marker -->
288
+ <div class="absolute top-6 bottom-[-600px] right-0 border-r border-dashed border-gray-400 opacity-30 h-[1000px] pointer-events-none" v-if="n < slideCount"></div>
289
  </div>
290
  </div>
291
  </div>
292
 
293
+ <!-- Elements Layer -->
294
  <div class="absolute inset-0 z-20 overflow-hidden">
295
  <div v-for="el in elements"
296
  :key="el.id"
297
  class="text-element absolute whitespace-nowrap p-2 rounded"
298
  :class="{ 'selected': selectedId === el.id }"
299
+ :style="getElementStyle(el)"
 
 
 
 
 
 
 
 
300
  @mousedown="startDrag($event, el)"
301
  @click.stop="selectElement(el)">
302
 
 
304
  @input="updateText(el, $event)"
305
  @blur="cleanupText(el)"
306
  class="outline-none min-w-[20px]"
307
+ :style="{ textAlign: el.textAlign }"
308
  v-html="el.text">
309
  </div>
310
  </div>
 
313
  </div>
314
  </main>
315
  </div>
316
+
317
+ <!-- Preview Modal -->
318
+ <div v-if="showPreviewModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-10" @click.self="showPreviewModal = false">
319
+ <div class="bg-white rounded-xl w-full max-w-6xl max-h-full flex flex-col overflow-hidden">
320
+ <div class="p-4 border-b flex justify-between items-center bg-gray-50">
321
+ <h3 class="font-bold text-gray-800">切片预览 (Gap Simulation)</h3>
322
+ <button @click="showPreviewModal = false" class="text-gray-500 hover:text-gray-800 text-2xl leading-none">&times;</button>
323
+ </div>
324
+ <div class="p-8 bg-gray-200 overflow-x-auto flex-1 custom-scroll">
325
+ <div class="flex gap-4 mx-auto w-fit">
326
+ <div v-for="(img, idx) in previewImages" :key="idx" class="relative group shadow-lg">
327
+ <img :src="img" class="h-[500px] object-contain bg-white">
328
+ <span class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded">{{idx+1}}</span>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ <div class="p-4 border-t flex justify-end gap-3 bg-white">
333
+ <button @click="showPreviewModal = false" class="px-4 py-2 text-gray-600 font-medium">关闭</button>
334
+ <button @click="downloadFromPreview" class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium shadow-md">下载所有切片</button>
335
+ </div>
336
  </div>
337
  </div>
338
+
339
+ <!-- Processing Overlay -->
340
+ <div v-if="isProcessing" class="fixed inset-0 z-[60] flex flex-col items-center justify-center bg-white/80 backdrop-blur-md">
341
+ <div class="loader mb-4 border-t-red-600 w-12 h-12 border-4"></div>
342
+ <p class="text-gray-800 font-bold text-lg animate-pulse">正在高清渲染切片...</p>
343
+ <p class="text-gray-500 text-sm mt-2">Generating {{ slideCount }} slides at {{ aspectRatio }}</p>
344
+ </div>
345
  </div>
346
 
347
  <script>
 
349
 
350
  createApp({
351
  setup() {
352
+ // State
353
  const slideCount = ref(4);
354
  const aspectRatio = ref('3:4');
355
  const bgImage = ref(null);
 
357
  const elements = ref([]);
358
  const selectedId = ref(null);
359
  const isProcessing = ref(false);
360
+ const showPreviewModal = ref(false);
361
+ const previewImages = ref([]);
362
 
363
+ // Constants
364
+ const BASE_HEIGHT = 600;
 
365
 
366
  const ratioMap = {
367
  '3:4': 3/4,
 
369
  '9:16': 9/16
370
  };
371
 
372
+ // Computed
373
+ const singleSlideWidth = computed(() => BASE_HEIGHT * ratioMap[aspectRatio.value]);
374
+ const totalWidth = computed(() => singleSlideWidth.value * slideCount.value);
375
+
376
+ const containerStyle = computed(() => ({
377
+ width: totalWidth.value + 'px',
378
+ height: BASE_HEIGHT + 'px'
379
+ }));
380
 
381
+ const selectedElement = computed(() => elements.value.find(e => e.id === selectedId.value));
 
 
382
 
383
+ // Methods
384
+ const initDefaultData = () => {
385
+ // Title spanning slide 1 and 2
386
+ elements.value.push({
387
+ id: 'title-1',
388
+ text: '如何制作<br>无缝轮播图',
389
+ x: singleSlideWidth.value, // Exactly on the first divider
390
+ y: BASE_HEIGHT * 0.4,
391
+ fontSize: 64,
392
+ fontWeight: 900,
393
+ color: '#1f2937',
394
+ fontFamily: "'Noto Sans SC', sans-serif",
395
+ textAlign: 'center',
396
+ hasBg: false,
397
+ bgColor: '#ffffff',
398
+ hasShadow: true,
399
+ isBold: true
400
+ });
401
+
402
+ // Subtitle
403
+ elements.value.push({
404
+ id: 'sub-1',
405
+ text: 'Carousel Maker Pro',
406
+ x: singleSlideWidth.value,
407
+ y: BASE_HEIGHT * 0.55,
408
+ fontSize: 24,
409
+ fontWeight: 400,
410
+ color: '#ef4444',
411
+ fontFamily: "'ZCOOL KuaiLe', cursive",
412
+ textAlign: 'center',
413
+ hasBg: true,
414
+ bgColor: '#fee2e2',
415
+ hasShadow: false,
416
+ isBold: false
417
+ });
418
 
419
+ addNumbering();
420
+ };
 
421
 
422
  const handleImageUpload = (e) => {
423
  const file = e.target.files[0];
 
429
 
430
  const addText = (text, size, weight) => {
431
  const id = Date.now();
432
+ // Place in center of current view or center of slide 1
433
  elements.value.push({
434
  id,
435
  text,
436
+ x: singleSlideWidth.value / 2,
437
  y: BASE_HEIGHT / 2,
438
  fontSize: size,
439
  fontWeight: weight,
440
  color: '#000000',
441
+ fontFamily: "'Noto Sans SC', sans-serif",
442
+ textAlign: 'center',
443
  hasBg: false,
444
+ bgColor: '#ffffff',
445
+ hasShadow: false,
446
+ isBold: weight > 600
447
  });
448
  selectedId.value = id;
449
  };
450
 
451
  const addNumbering = () => {
 
452
  elements.value = elements.value.filter(e => !e.isPageNumber);
 
453
  for(let i=0; i < slideCount.value; i++) {
454
  elements.value.push({
455
+ id: 'pg-' + i,
456
+ text: `${i+1} / ${slideCount.value}`,
457
  x: (i * singleSlideWidth.value) + (singleSlideWidth.value / 2),
458
+ y: BASE_HEIGHT - 40,
459
+ fontSize: 14,
460
  fontWeight: 400,
461
+ color: '#9ca3af',
462
+ fontFamily: "'Noto Sans SC', sans-serif",
463
+ textAlign: 'center',
464
+ hasBg: false,
465
  bgColor: '#000000',
466
+ hasShadow: false,
467
+ isBold: false,
468
  isPageNumber: true
469
  });
470
  }
471
  };
472
 
473
+ const selectElement = (el) => selectedId.value = el.id;
474
+
 
 
 
 
 
 
475
  const deleteSelected = () => {
476
  if (selectedId.value) {
477
  elements.value = elements.value.filter(e => e.id !== selectedId.value);
 
479
  }
480
  };
481
 
482
+ const getElementStyle = (el) => ({
483
+ left: el.x + 'px',
484
+ top: el.y + 'px',
485
+ color: el.color,
486
+ fontSize: el.fontSize + 'px',
487
+ fontWeight: el.isBold ? 'bold' : 'normal',
488
+ fontFamily: el.fontFamily,
489
+ backgroundColor: el.hasBg ? el.bgColor : 'transparent',
490
+ textShadow: el.hasShadow ? '2px 2px 4px rgba(0,0,0,0.3)' : 'none',
491
+ transform: 'translate(-50%, -50%)',
492
+ zIndex: selectedId.value === el.id ? 50 : 20
493
+ });
494
+
495
+ const updateText = (el, event) => el.text = event.target.innerHTML;
496
+ const cleanupText = (el) => { if(!el.text) el.text = "Double click"; };
497
+
498
+ // Drag Logic
499
  let dragOffset = { x: 0, y: 0 };
500
  let isDragging = false;
501
  let activeEl = null;
502
 
503
  const startDrag = (e, el) => {
504
+ if (e.target.isContentEditable) return;
505
  isDragging = true;
506
  activeEl = el;
507
+ dragOffset.x = e.clientX - el.x;
508
+ dragOffset.y = e.clientY - el.y;
 
 
 
 
 
509
  document.addEventListener('mousemove', onDrag);
510
  document.addEventListener('mouseup', stopDrag);
511
  };
512
 
513
  const onDrag = (e) => {
514
  if (!isDragging || !activeEl) return;
515
+ activeEl.x = e.clientX - dragOffset.x;
516
+ activeEl.y = e.clientY - dragOffset.y;
 
 
 
 
 
 
517
  };
518
 
519
  const stopDrag = () => {
 
523
  document.removeEventListener('mouseup', stopDrag);
524
  };
525
 
526
+ // Generate Images Logic
527
+ const generateSlices = async () => {
528
+ selectedId.value = null;
529
  isProcessing.value = true;
530
+ await new Promise(r => setTimeout(r, 100)); // Render cycle
 
531
 
532
+ const container = document.getElementById('canvas-container');
533
+ const scaleFactor = 1080 / singleSlideWidth.value;
534
+
535
  try {
 
 
 
 
 
 
536
  const canvas = await html2canvas(container, {
537
  scale: scaleFactor,
538
  useCORS: true,
539
+ backgroundColor: null,
540
+ logging: false
541
  });
542
 
543
+ const slices = [];
544
+ const slideW = 1080;
545
  const slideH = slideW / ratioMap[aspectRatio.value];
546
 
 
547
  for (let i = 0; i < slideCount.value; i++) {
548
  const sliceCanvas = document.createElement('canvas');
549
  sliceCanvas.width = slideW;
550
  sliceCanvas.height = slideH;
551
  const ctx = sliceCanvas.getContext('2d');
552
+ ctx.drawImage(canvas, i * slideW, 0, slideW, slideH, 0, 0, slideW, slideH);
553
 
554
+ const dataUrl = sliceCanvas.toDataURL('image/png');
555
+ slices.push({ blob: null, dataUrl }); // storing dataUrl for preview
 
 
 
 
 
 
 
556
  }
557
+ return slices;
 
 
 
558
  } catch (e) {
 
559
  console.error(e);
560
+ alert("生成失败,请重试");
561
+ return [];
562
  } finally {
563
  isProcessing.value = false;
564
  }
565
  };
566
 
567
+ const showPreview = async () => {
568
+ const slices = await generateSlices();
569
+ if (slices.length) {
570
+ previewImages.value = slices.map(s => s.dataUrl);
571
+ showPreviewModal.value = true;
572
+ }
573
+ };
574
+
575
+ const downloadFromPreview = async () => {
576
+ const zip = new JSZip();
577
+
578
+ // Convert DataURLs to Blobs for zip
579
+ previewImages.value.forEach((dataUrl, i) => {
580
+ const base64 = dataUrl.split(',')[1];
581
+ zip.file(`slide_${i+1}.png`, base64, {base64: true});
582
+ });
583
+
584
+ const content = await zip.generateAsync({type:"blob"});
585
+ saveAs(content, "carousel_slides.zip");
586
+ };
587
+
588
+ const exportImages = async () => {
589
+ const slices = await generateSlices();
590
+ if (slices.length) {
591
+ previewImages.value = slices.map(s => s.dataUrl);
592
+ downloadFromPreview(); // Direct download
593
+ }
594
+ };
595
+
596
+ onMounted(() => {
597
+ initDefaultData();
598
+ });
599
+
600
  return {
601
+ slideCount, aspectRatio, bgImage, fitMode, elements, selectedElement, selectedId,
602
+ isProcessing, showPreviewModal, previewImages,
603
+ singleSlideWidth, totalWidth, containerStyle,
604
+ handleImageUpload, addText, addNumbering, selectElement, updateText, cleanupText, deleteSelected,
605
+ getElementStyle, startDrag, showPreview, exportImages, downloadFromPreview
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  };
607
  }
608
  }).mount('#app');