Spaces:
Sleeping
Sleeping
Commit ·
812e9ed
1
Parent(s): 76257a0
Update: Enhance UI, add rich text features, default data, and preview mode
Browse files- README.md +22 -18
- app.py +8 -2
- 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 |
-
*
|
| 25 |
-
* **自动页码**:一键添加 "1/4", "2/4" 等样式统一的页码。
|
| 26 |
-
* **隐私安全**:纯前端渲染,图片不上传服务器,保护你的素材安全。
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
## 技术栈
|
| 33 |
|
| 34 |
-
* **Backend**: Flask (Python)
|
| 35 |
-
* **Frontend**: Vue 3 (Composition API) + Tailwind CSS
|
| 36 |
-
* **Core**:
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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:
|
| 26 |
}
|
| 27 |
.text-element.selected {
|
| 28 |
outline: 2px solid #2563eb;
|
|
|
|
| 29 |
}
|
|
|
|
|
|
|
| 30 |
[contenteditable]:empty:before {
|
| 31 |
content: attr(placeholder);
|
| 32 |
-
color:
|
| 33 |
}
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
-
.
|
| 39 |
background: #cbd5e1;
|
| 40 |
-
border-radius:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 48 |
<div class="flex items-center gap-3">
|
| 49 |
-
<div class="bg-red-500 text-white p-2 rounded-lg
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 55 |
-
<button @click="
|
| 56 |
-
<svg xmlns="http://www.w3.org/2000/svg" class="h-
|
| 57 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="
|
|
|
|
| 58 |
</svg>
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</button>
|
| 61 |
</div>
|
| 62 |
</header>
|
| 63 |
|
| 64 |
<div class="flex flex-1 overflow-hidden">
|
| 65 |
-
<!-- Sidebar
|
| 66 |
-
<aside class="w-80 bg-white border-r
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
<
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<
|
| 82 |
-
<
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
-
</
|
| 88 |
-
</div>
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
<
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</div>
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
<
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</div>
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
| 147 |
</div>
|
| 148 |
-
</
|
| 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 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
</div>
|
| 156 |
</aside>
|
| 157 |
|
| 158 |
<!-- Main Workspace -->
|
| 159 |
-
<main class="flex-1 bg-gray-200 overflow-auto
|
| 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
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
<!-- Grid
|
| 178 |
-
<div class="absolute inset-0 slide-grid pointer-events-none
|
| 179 |
:style="{'--slide-width': singleSlideWidth + 'px'}">
|
| 180 |
-
<!--
|
| 181 |
-
<div class="absolute top-
|
| 182 |
-
<div v-for="n in slideCount" :key="n" class="flex-1
|
| 183 |
-
<span class="bg-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
-
<!--
|
| 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 |
-
<!--
|
| 222 |
-
<div v-if="
|
| 223 |
-
<div class="bg-white rounded-xl
|
| 224 |
-
<div class="
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 244 |
-
|
| 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 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
const
|
| 258 |
-
return singleSlideWidth.value * slideCount.value;
|
| 259 |
-
});
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 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:
|
| 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:
|
| 303 |
-
text: `${i+1}
|
| 304 |
x: (i * singleSlideWidth.value) + (singleSlideWidth.value / 2),
|
| 305 |
-
y: BASE_HEIGHT -
|
| 306 |
-
fontSize:
|
| 307 |
fontWeight: 400,
|
| 308 |
-
color: '#
|
| 309 |
-
|
|
|
|
|
|
|
| 310 |
bgColor: '#000000',
|
|
|
|
|
|
|
| 311 |
isPageNumber: true
|
| 312 |
});
|
| 313 |
}
|
| 314 |
};
|
| 315 |
|
| 316 |
-
const selectElement = (el) =>
|
| 317 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 338 |
isDragging = true;
|
| 339 |
activeEl = el;
|
| 340 |
-
dragOffset.x = e.clientX - el.x;
|
| 341 |
-
|
| 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 |
-
|
| 354 |
-
|
| 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 |
-
//
|
| 371 |
-
const
|
| 372 |
-
selectedId.value = null;
|
| 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
|
| 391 |
-
const slideW = 1080;
|
| 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 |
-
|
| 402 |
-
|
| 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 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">×</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');
|