Trae Assistant commited on
Commit
82eacf2
·
0 Parent(s):

Initial commit: Certificate Master

Browse files
Files changed (6) hide show
  1. .gitignore +8 -0
  2. Dockerfile +24 -0
  3. README.md +73 -0
  4. app.py +143 -0
  5. requirements.txt +3 -0
  6. templates/index.html +517 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .DS_Store
5
+ .env
6
+ venv/
7
+ .idea/
8
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Install Chinese fonts for Pillow to use as fallback
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ fonts-wqy-zenhei \
6
+ fonts-noto-cjk \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY . .
15
+
16
+ # Create a user to avoid running as root (good practice for HF Spaces)
17
+ RUN useradd -m -u 1000 user
18
+ USER user
19
+ ENV HOME=/home/user \
20
+ PATH=/home/user/.local/bin:$PATH
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Certificate Master
3
+ emoji: 🎓
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: 批量证书生成大师 - 可视化拖拽设计
10
+ app_port: 7860
11
+ ---
12
+
13
+ # 🎓 证书批量生成大师 (Certificate Master)
14
+
15
+ 一个强大且易用的在线批量证书/奖状生成工具。专为教育机构、社群运营、课程讲师设计,帮助你快速为学员生成个性化的证书图片。
16
+
17
+ ## ✨ 核心功能
18
+
19
+ * **🎨 可视化设计**: 上传背景图,通过拖拽自由排版文字位置。
20
+ * **📝 数据批量导入**: 支持 JSON 格式批量导入学员名单、日期、编号等数据。
21
+ * **✒️ 字体自定义**: 支持上传自定义字体文件(ttf/otf),完美还原设计稿。
22
+ * **👁️ 实时预览**: 所见即所得,实时查看生成效果。
23
+ * **📦 批量导出**: 一键生成所有证书并打包为 ZIP 下载。
24
+ * **🔒 隐私安全**: 核心生成逻辑在服务器内存中完成,不保存任何用户数据。
25
+
26
+ ## 🚀 快速开始
27
+
28
+ ### 本地运行
29
+
30
+ 1. 克隆仓库:
31
+ ```bash
32
+ git clone https://github.com/your-username/certificate-master.git
33
+ cd certificate-master
34
+ ```
35
+
36
+ 2. 安装依赖:
37
+ ```bash
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ 3. 启动服务:
42
+ ```bash
43
+ python app.py
44
+ ```
45
+
46
+ 4. 打开浏览器访问:`http://localhost:7860`
47
+
48
+ ### Docker 部署
49
+
50
+ ```bash
51
+ docker build -t certificate-master .
52
+ docker run -p 7860:7860 certificate-master
53
+ ```
54
+
55
+ ## 🛠️ 使用指南
56
+
57
+ 1. **准备素材**: 准备一张空白的证书背景图(推荐 JPG/PNG)。
58
+ 2. **上传背景**: 在左侧面板上传背景图。
59
+ 3. **添加字段**: 点击“添加文本字段”,在画布上拖拽调整位置。
60
+ 4. **配置数据**: 在数据录入区输入 JSON 列表,例如 `[{"name": "张三"}, {"name": "李四"}]`。
61
+ 5. **关联变量**: 在字段设置中,将内容模板设为 `{name}` 即可自动替换。
62
+ 6. **调整样式**: 设置字体大小、颜色、对齐方式。
63
+ 7. **生成下载**: 点击右上角按钮,等待生成并下载 ZIP 包。
64
+
65
+ ## 💻 技术栈
66
+
67
+ * **Frontend**: Vue 3 + Tailwind CSS (无需构建,CDN引入)
68
+ * **Backend**: Flask + Pillow (Python图像处理)
69
+ * **Deployment**: Docker
70
+
71
+ ## 🤝 贡献
72
+
73
+ 欢迎提交 Issue 和 PR!
app.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import io
4
+ import zipfile
5
+ from flask import Flask, render_template, request, send_file, jsonify
6
+ from PIL import Image, ImageDraw, ImageFont, ImageColor
7
+
8
+ app = Flask(__name__)
9
+
10
+ # Default font paths to try if no font is uploaded
11
+ DEFAULT_FONTS = [
12
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
13
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
14
+ "/System/Library/Fonts/PingFang.ttc", # MacOS
15
+ "simhei.ttf", # Windows/Local
16
+ "arial.ttf" # Fallback English
17
+ ]
18
+
19
+ def load_font(font_file, size):
20
+ """Load a font from a file object or default system paths."""
21
+ if font_file:
22
+ return ImageFont.truetype(font_file, size)
23
+
24
+ for font_path in DEFAULT_FONTS:
25
+ try:
26
+ return ImageFont.truetype(font_path, size)
27
+ except OSError:
28
+ continue
29
+
30
+ # Absolute fallback
31
+ return ImageFont.load_default()
32
+
33
+ @app.route('/')
34
+ def index():
35
+ return render_template('index.html')
36
+
37
+ @app.route('/api/generate', methods=['POST'])
38
+ def generate_certificates():
39
+ try:
40
+ # 1. Get Resources
41
+ if 'background' not in request.files:
42
+ return jsonify({"error": "No background image provided"}), 400
43
+
44
+ bg_file = request.files['background']
45
+ font_file = request.files.get('font')
46
+
47
+ # 2. Get Data & Config
48
+ # config: [{"id": "name", "x": 100, "y": 100, "size": 40, "color": "#000000", "align": "left", "template": "{name}"}]
49
+ config_str = request.form.get('config', '[]')
50
+ # data: [{"name": "Alice", "date": "2023-01-01"}, ...]
51
+ data_str = request.form.get('data', '[]')
52
+
53
+ try:
54
+ config = json.loads(config_str)
55
+ data_list = json.loads(data_str)
56
+ except json.JSONDecodeError:
57
+ return jsonify({"error": "Invalid JSON in config or data"}), 400
58
+
59
+ if not data_list:
60
+ return jsonify({"error": "No data rows provided"}), 400
61
+
62
+ # 3. Prepare ZIP
63
+ zip_buffer = io.BytesIO()
64
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
65
+
66
+ # Load background once to get dimensions/mode
67
+ bg_image = Image.open(bg_file).convert("RGB")
68
+
69
+ # Load font bytes once if provided, so we can reuse them
70
+ font_bytes = None
71
+ if font_file:
72
+ font_bytes = io.BytesIO(font_file.read())
73
+
74
+ for idx, row in enumerate(data_list):
75
+ # Create a fresh copy for each certificate
76
+ cert_img = bg_image.copy()
77
+ draw = ImageDraw.Draw(cert_img)
78
+
79
+ for field in config:
80
+ # Parse Config
81
+ text_template = field.get('template', '')
82
+ x = int(field.get('x', 0))
83
+ y = int(field.get('y', 0))
84
+ size = int(field.get('size', 30))
85
+ color = field.get('color', '#000000')
86
+ align = field.get('align', 'left') # left, center, right
87
+
88
+ # Format Text
89
+ try:
90
+ text = text_template.format(**row)
91
+ except KeyError as e:
92
+ text = text_template # Fallback if key missing
93
+
94
+ # Load Font (We create a new font obj for each size, but reusing bytes if possible)
95
+ if font_bytes:
96
+ font_bytes.seek(0)
97
+ font = ImageFont.truetype(font_bytes, size)
98
+ else:
99
+ font = load_font(None, size)
100
+
101
+ # Calculate Anchor/Position
102
+ # Pillow's text method supports 'anchor' in newer versions, but let's do manual calc for compatibility if needed
103
+ # 'la' (Left Ascender) is default. 'mm' is middle middle. 'ma' is middle ascender.
104
+ # Simple alignment handling:
105
+
106
+ anchor = 'la' # default left-top-ish (ascender)
107
+ if align == 'center':
108
+ anchor = 'ma' # middle ascender (top-centered)
109
+ elif align == 'right':
110
+ anchor = 'ra' # right ascender
111
+
112
+ # Draw
113
+ draw.text((x, y), text, fill=color, font=font, anchor=anchor)
114
+
115
+ # Save individual file to ZIP
116
+ img_buffer = io.BytesIO()
117
+ cert_img.save(img_buffer, format="PNG")
118
+
119
+ # Naming: Use first field or index
120
+ filename = f"certificate_{idx+1}.png"
121
+ if len(config) > 0 and config[0]['id'] in row:
122
+ # Try to use the value of the first configured field as filename safe string
123
+ safe_name = "".join([c for c in str(row[config[0]['id']]) if c.isalnum() or c in (' ', '-', '_')]).strip()
124
+ if safe_name:
125
+ filename = f"{safe_name}.png"
126
+
127
+ zip_file.writestr(filename, img_buffer.getvalue())
128
+
129
+ zip_buffer.seek(0)
130
+ return send_file(
131
+ zip_buffer,
132
+ mimetype='application/zip',
133
+ as_attachment=True,
134
+ download_name='certificates.zip'
135
+ )
136
+
137
+ except Exception as e:
138
+ import traceback
139
+ traceback.print_exc()
140
+ return jsonify({"error": str(e)}), 500
141
+
142
+ if __name__ == '__main__':
143
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Flask>=3.0.0
2
+ Pillow>=10.0.0
3
+ gunicorn>=21.2.0
templates/index.html ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>证书批量生成大师 | Certificate Master</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
10
+ <style>
11
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
12
+ body { font-family: 'Inter', sans-serif; }
13
+ .canvas-container {
14
+ position: relative;
15
+ overflow: hidden;
16
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
17
+ background-image:
18
+ linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
19
+ linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
20
+ linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
21
+ linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
22
+ background-size: 20px 20px;
23
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
24
+ }
25
+ .draggable-field {
26
+ position: absolute;
27
+ cursor: move;
28
+ user-select: none;
29
+ border: 1px dashed transparent;
30
+ white-space: nowrap;
31
+ }
32
+ .draggable-field:hover, .draggable-field.active {
33
+ border-color: #3b82f6;
34
+ background-color: rgba(59, 130, 246, 0.1);
35
+ }
36
+ .draggable-field.active::after {
37
+ content: '';
38
+ position: absolute;
39
+ top: -4px; left: -4px; right: -4px; bottom: -4px;
40
+ border: 1px solid #2563eb;
41
+ pointer-events: none;
42
+ }
43
+ /* Hide scrollbar for clean UI */
44
+ ::-webkit-scrollbar {
45
+ width: 8px;
46
+ height: 8px;
47
+ }
48
+ ::-webkit-scrollbar-track {
49
+ background: #f1f1f1;
50
+ }
51
+ ::-webkit-scrollbar-thumb {
52
+ background: #c1c1c1;
53
+ border-radius: 4px;
54
+ }
55
+ ::-webkit-scrollbar-thumb:hover {
56
+ background: #a8a8a8;
57
+ }
58
+ </style>
59
+ </head>
60
+ <body class="bg-gray-50 text-gray-800 h-screen flex flex-col">
61
+
62
+ <div id="app" class="flex flex-col h-full">
63
+ <!-- Header -->
64
+ <header class="bg-white border-b border-gray-200 px-6 py-3 flex justify-between items-center flex-shrink-0 z-10">
65
+ <div class="flex items-center gap-3">
66
+ <div class="bg-blue-600 text-white p-2 rounded-lg">
67
+ <i class="fas fa-certificate text-xl"></i>
68
+ </div>
69
+ <div>
70
+ <h1 class="text-xl font-bold text-gray-900">Certificate Master</h1>
71
+ <p class="text-xs text-gray-500">批量证书生成大师</p>
72
+ </div>
73
+ </div>
74
+ <div class="flex gap-3">
75
+ <button @click="generateCertificates" :disabled="isGenerating || !bgImage" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg shadow-sm transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
76
+ <i class="fas fa-download" :class="{'fa-spin': isGenerating}"></i>
77
+ <span v-if="isGenerating">生成中...</span>
78
+ <span v-else>生成并下载 ZIP</span>
79
+ </button>
80
+ </div>
81
+ </header>
82
+
83
+ <!-- Main Content -->
84
+ <div class="flex-1 flex overflow-hidden">
85
+
86
+ <!-- Left Sidebar: Controls -->
87
+ <div class="w-80 bg-white border-r border-gray-200 flex flex-col overflow-y-auto flex-shrink-0">
88
+
89
+ <!-- 1. Uploads -->
90
+ <div class="p-5 border-b border-gray-100">
91
+ <h2 class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-4">1. 资源素材</h2>
92
+
93
+ <!-- BG Upload -->
94
+ <div class="mb-4">
95
+ <label class="block text-sm font-medium text-gray-700 mb-1">背景图片 (必须)</label>
96
+ <div class="relative border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:bg-gray-50 transition-colors cursor-pointer" @click="$refs.bgInput.click()">
97
+ <input type="file" ref="bgInput" class="hidden" accept="image/*" @change="handleBgUpload">
98
+ <div v-if="bgFile" class="text-sm text-green-600 truncate">
99
+ <i class="fas fa-check-circle mr-1"></i> {{ bgFile.name }}
100
+ </div>
101
+ <div v-else class="text-gray-400 text-sm">
102
+ <i class="fas fa-image text-2xl mb-2 block"></i>
103
+ 点击上传背景图
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- Font Upload -->
109
+ <div>
110
+ <label class="block text-sm font-medium text-gray-700 mb-1">字体文件 (可选)</label>
111
+ <div class="relative border border-gray-300 rounded-lg p-3 flex items-center gap-3 hover:bg-gray-50 cursor-pointer" @click="$refs.fontInput.click()">
112
+ <input type="file" ref="fontInput" class="hidden" accept=".ttf,.otf,.woff" @change="handleFontUpload">
113
+ <div class="bg-gray-100 p-2 rounded text-gray-500">
114
+ <i class="fas fa-font"></i>
115
+ </div>
116
+ <div class="flex-1 min-w-0">
117
+ <div class="text-sm font-medium text-gray-900 truncate">
118
+ {{ fontFile ? fontFile.name : '使用系统默认字体' }}
119
+ </div>
120
+ <div class="text-xs text-gray-500">支持 ttf, otf</div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- 2. Data Input -->
127
+ <div class="p-5 border-b border-gray-100 flex-1">
128
+ <div class="flex justify-between items-center mb-4">
129
+ <h2 class="text-sm font-bold text-gray-400 uppercase tracking-wider">2. 数据录入</h2>
130
+ <button @click="loadDemoData" class="text-xs text-blue-600 hover:text-blue-800 underline">加载示例</button>
131
+ </div>
132
+
133
+ <div class="mb-2">
134
+ <label class="block text-xs text-gray-500 mb-1">输入 JSON 数据 (每行一个对象的列表,或直接 JSON)</label>
135
+ <textarea v-model="dataInput" class="w-full h-40 p-3 text-xs font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none" placeholder='[
136
+ {"name": "张三", "date": "2023-10-01"},
137
+ {"name": "李四", "date": "2023-10-02"}
138
+ ]'></textarea>
139
+ </div>
140
+ <div class="text-xs text-gray-400">
141
+ <i class="fas fa-info-circle mr-1"></i> 当前共 {{ parsedData.length }} 条记录
142
+ </div>
143
+ </div>
144
+
145
+ <!-- 3. Add Fields -->
146
+ <div class="p-5 bg-gray-50 mt-auto">
147
+ <button @click="addField" class="w-full py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 font-medium hover:bg-gray-50 hover:text-blue-600 transition-all">
148
+ <i class="fas fa-plus mr-2"></i> 添加文本字段
149
+ </button>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Center: Canvas Area -->
154
+ <div class="flex-1 bg-gray-100 flex items-center justify-center p-10 overflow-auto relative" @mousedown.self="activeFieldId = null">
155
+
156
+ <div v-if="bgImage" class="canvas-container shadow-2xl transition-all duration-300"
157
+ :style="{ width: displayWidth + 'px', height: displayHeight + 'px' }"
158
+ ref="canvasContainer">
159
+
160
+ <!-- Background Image -->
161
+ <img :src="bgImage" class="w-full h-full object-contain pointer-events-none select-none" alt="Certificate Background">
162
+
163
+ <!-- Draggable Fields -->
164
+ <div v-for="field in fields" :key="field.id"
165
+ class="draggable-field"
166
+ :class="{ 'active': activeFieldId === field.id }"
167
+ :style="getFieldStyle(field)"
168
+ @mousedown="startDrag($event, field)"
169
+ @click.stop="activeFieldId = field.id">
170
+ {{ getPreviewText(field) }}
171
+ </div>
172
+
173
+ </div>
174
+
175
+ <div v-else class="text-center text-gray-400">
176
+ <div class="text-6xl mb-4 text-gray-300">
177
+ <i class="fas fa-image"></i>
178
+ </div>
179
+ <p class="text-lg">请先在左侧上传背景图片</p>
180
+ </div>
181
+
182
+ <!-- Zoom Controls -->
183
+ <div v-if="bgImage" class="absolute bottom-6 right-6 bg-white rounded-lg shadow-lg flex items-center p-1 border border-gray-200">
184
+ <button @click="zoomOut" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"><i class="fas fa-minus"></i></button>
185
+ <span class="text-xs font-mono w-12 text-center">{{ Math.round(scale * 100) }}%</span>
186
+ <button @click="zoomIn" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"><i class="fas fa-plus"></i></button>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Right Sidebar: Field Settings -->
191
+ <div v-if="activeField" class="w-72 bg-white border-l border-gray-200 flex flex-col flex-shrink-0 transition-all">
192
+ <div class="p-4 border-b border-gray-200 flex justify-between items-center bg-gray-50">
193
+ <h3 class="font-bold text-gray-700">字段设置</h3>
194
+ <button @click="removeField(activeField.id)" class="text-red-500 hover:text-red-700 text-sm">
195
+ <i class="fas fa-trash-alt"></i> 删除
196
+ </button>
197
+ </div>
198
+
199
+ <div class="p-4 space-y-4 overflow-y-auto flex-1">
200
+
201
+ <!-- Template -->
202
+ <div>
203
+ <label class="block text-xs font-medium text-gray-500 mb-1">内容模板 (支持 {key})</label>
204
+ <input type="text" v-model="activeField.template" class="w-full p-2 border border-gray-300 rounded text-sm focus:border-blue-500 outline-none">
205
+ <div class="text-xs text-gray-400 mt-1">
206
+ 可用变量: <span v-for="key in dataKeys" :key="key" class="inline-block bg-gray-100 px-1 rounded mr-1 cursor-pointer hover:text-blue-600" @click="activeField.template += '{' + key + '}'">{{ key }}</span>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- Font Size -->
211
+ <div>
212
+ <label class="block text-xs font-medium text-gray-500 mb-1">字体大小 (px)</label>
213
+ <div class="flex items-center gap-2">
214
+ <input type="range" v-model.number="activeField.size" min="10" max="200" class="flex-1">
215
+ <input type="number" v-model.number="activeField.size" class="w-16 p-1 border border-gray-300 rounded text-sm text-center">
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Color -->
220
+ <div>
221
+ <label class="block text-xs font-medium text-gray-500 mb-1">颜色</label>
222
+ <div class="flex gap-2">
223
+ <input type="color" v-model="activeField.color" class="h-8 w-8 rounded cursor-pointer border-0 p-0">
224
+ <input type="text" v-model="activeField.color" class="flex-1 p-1 border border-gray-300 rounded text-sm">
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Alignment -->
229
+ <div>
230
+ <label class="block text-xs font-medium text-gray-500 mb-1">对齐方式</label>
231
+ <div class="flex border border-gray-300 rounded overflow-hidden">
232
+ <button @click="activeField.align = 'left'" class="flex-1 py-1.5 text-sm hover:bg-gray-50" :class="{'bg-blue-50 text-blue-600': activeField.align === 'left'}"><i class="fas fa-align-left"></i></button>
233
+ <button @click="activeField.align = 'center'" class="flex-1 py-1.5 text-sm hover:bg-gray-50 border-l border-r border-gray-300" :class="{'bg-blue-50 text-blue-600': activeField.align === 'center'}"><i class="fas fa-align-center"></i></button>
234
+ <button @click="activeField.align = 'right'" class="flex-1 py-1.5 text-sm hover:bg-gray-50" :class="{'bg-blue-50 text-blue-600': activeField.align === 'right'}"><i class="fas fa-align-right"></i></button>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Position (Fine tuning) -->
239
+ <div class="grid grid-cols-2 gap-2">
240
+ <div>
241
+ <label class="block text-xs font-medium text-gray-500 mb-1">X 坐标</label>
242
+ <input type="number" v-model.number="activeField.x" class="w-full p-2 border border-gray-300 rounded text-sm">
243
+ </div>
244
+ <div>
245
+ <label class="block text-xs font-medium text-gray-500 mb-1">Y 坐标</label>
246
+ <input type="number" v-model.number="activeField.y" class="w-full p-2 border border-gray-300 rounded text-sm">
247
+ </div>
248
+ </div>
249
+
250
+ </div>
251
+ </div>
252
+
253
+ <!-- Empty State for Right Sidebar -->
254
+ <div v-else class="w-72 bg-white border-l border-gray-200 flex items-center justify-center text-gray-400 text-sm flex-shrink-0">
255
+ <p>点击画布上的文字进行编辑</p>
256
+ </div>
257
+
258
+ </div>
259
+ </div>
260
+
261
+ <script>
262
+ const { createApp, ref, computed, onMounted, watch } = Vue;
263
+
264
+ createApp({
265
+ setup() {
266
+ // State
267
+ const bgImage = ref(null); // Blob URL
268
+ const bgFile = ref(null);
269
+ const bgDimensions = ref({ width: 0, height: 0 });
270
+
271
+ const fontFile = ref(null);
272
+ const customFontFamily = ref('Inter'); // Default
273
+
274
+ const fields = ref([]);
275
+ const activeFieldId = ref(null);
276
+
277
+ const dataInput = ref(JSON.stringify([
278
+ { name: "张三", course: "Python 进阶课程", date: "2023年10月15日", id: "CERT-001" },
279
+ { name: "Alice", course: "Advanced Python", date: "Oct 15, 2023", id: "CERT-002" }
280
+ ], null, 2));
281
+
282
+ const scale = ref(0.5); // Viewport scale
283
+ const isGenerating = ref(false);
284
+
285
+ // Computed
286
+ const parsedData = computed(() => {
287
+ try {
288
+ const d = JSON.parse(dataInput.value);
289
+ return Array.isArray(d) ? d : [];
290
+ } catch (e) {
291
+ return [];
292
+ }
293
+ });
294
+
295
+ const dataKeys = computed(() => {
296
+ if (parsedData.value.length > 0) {
297
+ return Object.keys(parsedData.value[0]);
298
+ }
299
+ return ['name', 'date', 'id'];
300
+ });
301
+
302
+ const activeField = computed(() => {
303
+ return fields.value.find(f => f.id === activeFieldId.value);
304
+ });
305
+
306
+ const displayWidth = computed(() => bgDimensions.value.width * scale.value);
307
+ const displayHeight = computed(() => bgDimensions.value.height * scale.value);
308
+
309
+ // Methods
310
+ const handleBgUpload = (event) => {
311
+ const file = event.target.files[0];
312
+ if (!file) return;
313
+
314
+ bgFile.value = file;
315
+ const url = URL.createObjectURL(file);
316
+ bgImage.value = url;
317
+
318
+ const img = new Image();
319
+ img.onload = () => {
320
+ bgDimensions.value = { width: img.width, height: img.height };
321
+ // Auto fit scale
322
+ const containerH = window.innerHeight - 100; // rough calc
323
+ const containerW = window.innerWidth - 600;
324
+ const scaleW = containerW / img.width;
325
+ const scaleH = containerH / img.height;
326
+ scale.value = Math.min(Math.min(scaleW, scaleH), 1) * 0.9;
327
+ };
328
+ img.src = url;
329
+ };
330
+
331
+ const handleFontUpload = async (event) => {
332
+ const file = event.target.files[0];
333
+ if (!file) return;
334
+
335
+ fontFile.value = file;
336
+
337
+ // Load font into browser for preview
338
+ const fontName = 'CustomFont-' + Date.now();
339
+ const fontData = await file.arrayBuffer();
340
+ const fontFace = new FontFace(fontName, fontData);
341
+
342
+ try {
343
+ await fontFace.load();
344
+ document.fonts.add(fontFace);
345
+ customFontFamily.value = fontName;
346
+ console.log('Font loaded:', fontName);
347
+ } catch (e) {
348
+ console.error('Failed to load font preview', e);
349
+ alert('字体预览加载失败,但生成时仍会生效');
350
+ }
351
+ };
352
+
353
+ const addField = () => {
354
+ if (!bgImage.value) {
355
+ alert("请先上传背景图片");
356
+ return;
357
+ }
358
+
359
+ const id = Date.now().toString();
360
+ fields.value.push({
361
+ id,
362
+ x: bgDimensions.value.width / 2,
363
+ y: bgDimensions.value.height / 2,
364
+ size: 60,
365
+ color: '#000000',
366
+ align: 'center',
367
+ template: '{name}'
368
+ });
369
+ activeFieldId.value = id;
370
+ };
371
+
372
+ const removeField = (id) => {
373
+ fields.value = fields.value.filter(f => f.id !== id);
374
+ if (activeFieldId.value === id) activeFieldId.value = null;
375
+ };
376
+
377
+ const getPreviewText = (field) => {
378
+ const row = parsedData.value[0] || {};
379
+ let text = field.template;
380
+ for (const [key, value] of Object.entries(row)) {
381
+ text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
382
+ }
383
+ return text;
384
+ };
385
+
386
+ const getFieldStyle = (field) => {
387
+ const s = scale.value;
388
+ let transform = 'translate(-50%, -50%)'; // Default for center?
389
+ // Adjust transform based on align to match Pillow's anchor logic loosely
390
+ // Actually, let's keep it simple: Position is the anchor point.
391
+
392
+ if (field.align === 'left') transform = 'translate(0, -50%)'; // Anchor Left-Center (Vertical center usually for text block in CSS? No, font baseline is tricky)
393
+ // CSS Text positioning: top-left of the box.
394
+ // Pillow default anchor 'la' = Left Ascender.
395
+ // Let's assume CSS 'top' aligns with Pillow 'y'.
396
+
397
+ // To simplify: CSS top/left is set to the point (field.x * s, field.y * s).
398
+ // We use transform to handle alignment relative to that point.
399
+
400
+ let xOffset = '0%';
401
+ if (field.align === 'center') xOffset = '-50%';
402
+ if (field.align === 'right') xOffset = '-100%';
403
+
404
+ // Pillow 'ascender' is roughly the top of the text.
405
+ // CSS 'top' is top of the box. So it should match fairly well.
406
+
407
+ return {
408
+ left: (field.x * s) + 'px',
409
+ top: (field.y * s) + 'px',
410
+ fontSize: (field.size * s) + 'px',
411
+ color: field.color,
412
+ fontFamily: customFontFamily.value,
413
+ transform: `translate(${xOffset}, 0%)`, // We don't vertically center, we align top (Pillow default is usually top-ish)
414
+ // Wait, Pillow 'la' is Left Ascender. Ascender is top.
415
+ // So no vertical translation needed if we assume Y is top.
416
+ textAlign: field.align
417
+ };
418
+ };
419
+
420
+ const loadDemoData = () => {
421
+ dataInput.value = JSON.stringify([
422
+ { name: "王小明", date: "2024-01-01", id: "NO.001" },
423
+ { name: "李华", date: "2024-01-02", id: "NO.002" },
424
+ { name: "张伟", date: "2024-01-03", id: "NO.003" }
425
+ ], null, 2);
426
+ };
427
+
428
+ const zoomIn = () => scale.value *= 1.1;
429
+ const zoomOut = () => scale.value *= 0.9;
430
+
431
+ // Dragging Logic
432
+ const startDrag = (event, field) => {
433
+ activeFieldId.value = field.id;
434
+
435
+ const startX = event.clientX;
436
+ const startY = event.clientY;
437
+ const startFieldX = field.x;
438
+ const startFieldY = field.y;
439
+
440
+ const onMouseMove = (e) => {
441
+ const dx = (e.clientX - startX) / scale.value;
442
+ const dy = (e.clientY - startY) / scale.value;
443
+
444
+ field.x = Math.round(startFieldX + dx);
445
+ field.y = Math.round(startFieldY + dy);
446
+ };
447
+
448
+ const onMouseUp = () => {
449
+ document.removeEventListener('mousemove', onMouseMove);
450
+ document.removeEventListener('mouseup', onMouseUp);
451
+ };
452
+
453
+ document.addEventListener('mousemove', onMouseMove);
454
+ document.addEventListener('mouseup', onMouseUp);
455
+ };
456
+
457
+ const generateCertificates = async () => {
458
+ if (!bgFile.value) return;
459
+ isGenerating.value = true;
460
+
461
+ const formData = new FormData();
462
+ formData.append('background', bgFile.value);
463
+ if (fontFile.value) {
464
+ formData.append('font', fontFile.value);
465
+ }
466
+ formData.append('config', JSON.stringify(fields.value));
467
+ formData.append('data', dataInput.value);
468
+
469
+ try {
470
+ const res = await fetch('/api/generate', {
471
+ method: 'POST',
472
+ body: formData
473
+ });
474
+
475
+ if (!res.ok) {
476
+ const err = await res.json();
477
+ alert('生成失败: ' + (err.error || res.statusText));
478
+ return;
479
+ }
480
+
481
+ // Download Blob
482
+ const blob = await res.blob();
483
+ const url = window.URL.createObjectURL(blob);
484
+ const a = document.createElement('a');
485
+ a.href = url;
486
+ a.download = 'certificates.zip';
487
+ document.body.appendChild(a);
488
+ a.click();
489
+ document.body.removeChild(a);
490
+ window.URL.revokeObjectURL(url);
491
+
492
+ } catch (e) {
493
+ alert('网络错误: ' + e.message);
494
+ } finally {
495
+ isGenerating.value = false;
496
+ }
497
+ };
498
+
499
+ return {
500
+ bgImage, bgFile, bgDimensions,
501
+ fontFile, customFontFamily,
502
+ fields, activeField, activeFieldId,
503
+ dataInput, parsedData, dataKeys,
504
+ scale, displayWidth, displayHeight,
505
+ isGenerating,
506
+ handleBgUpload, handleFontUpload,
507
+ addField, removeField,
508
+ getPreviewText, getFieldStyle,
509
+ startDrag,
510
+ zoomIn, zoomOut,
511
+ generateCertificates, loadDemoData
512
+ };
513
+ }
514
+ }).mount('#app');
515
+ </script>
516
+ </body>
517
+ </html>