Spaces:
Sleeping
Sleeping
Commit ·
76257a0
0
Parent(s):
Initial commit: Carousel Maker Pro
Browse files- Dockerfile +18 -0
- README.md +58 -0
- app.py +15 -0
- requirements.txt +3 -0
- templates/index.html +448 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
RUN useradd -m -u 1000 user
|
| 11 |
+
USER user
|
| 12 |
+
|
| 13 |
+
ENV HOME=/home/user \
|
| 14 |
+
PATH=/home/user/.local/bin:$PATH
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
|
README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Carousel Maker Pro
|
| 3 |
+
emoji: 🎞️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
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
|
| 45 |
+
pip install -r requirements.txt
|
| 46 |
+
```
|
| 47 |
+
2. 运行应用:
|
| 48 |
+
```bash
|
| 49 |
+
python app.py
|
| 50 |
+
```
|
| 51 |
+
3. 访问: `http://localhost:7860`
|
| 52 |
+
|
| 53 |
+
## 部署到 Hugging Face Spaces
|
| 54 |
+
|
| 55 |
+
本项目包含 Dockerfile,可直接部署。
|
| 56 |
+
1. 新建 Space,SDK 选择 Docker。
|
| 57 |
+
2. 上传代码。
|
| 58 |
+
3. 等待构建完成即可使用。
|
app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 6 |
+
@app.route('/')
|
| 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)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.0.0
|
| 2 |
+
Pillow==10.1.0
|
| 3 |
+
gunicorn==21.2.0
|
templates/index.html
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>小红书轮播图制作神器 | 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 |
+
|
| 167 |
+
<!-- Background Layer -->
|
| 168 |
+
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
| 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 |
+
|
| 208 |
+
<div contenteditable="true"
|
| 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>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 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>
|
| 231 |
+
const { createApp, ref, computed, onMounted } = Vue;
|
| 232 |
+
|
| 233 |
+
createApp({
|
| 234 |
+
setup() {
|
| 235 |
+
const slideCount = ref(4);
|
| 236 |
+
const aspectRatio = ref('3:4');
|
| 237 |
+
const bgImage = ref(null);
|
| 238 |
+
const fitMode = ref('cover');
|
| 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,
|
| 249 |
+
'1:1': 1,
|
| 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];
|
| 274 |
+
if (!file) return;
|
| 275 |
+
const reader = new FileReader();
|
| 276 |
+
reader.onload = (e) => bgImage.value = e.target.result;
|
| 277 |
+
reader.readAsDataURL(file);
|
| 278 |
+
};
|
| 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);
|
| 327 |
+
selectedId.value = null;
|
| 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 = () => {
|
| 364 |
+
isDragging = false;
|
| 365 |
+
activeEl = null;
|
| 366 |
+
document.removeEventListener('mousemove', onDrag);
|
| 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');
|
| 446 |
+
</script>
|
| 447 |
+
</body>
|
| 448 |
+
</html>
|