Files
image-generator/index.html
zwbcc ec17835c68 refactor: 完整重构,支持 MiniMax 全部文生图参数
- 新增 image-01 / image-01-live 双模型切换
- image-01-live 支持画风类型 + 权重
- 支持生成数量 (n=1-9)、随机种子、自定义分辨率
- 支持自动优化 prompt、AI 水印开关
- 支持 URL / Base64 双输出格式
- 任务 ID + 成功/失败计数显示
- 错误码友好提示(限流/余额/敏感内容等)
- Node.js 22 内置 fetch 替代 node-fetch
2026-03-25 23:58:11 +08:00

542 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图片生成器</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- Header -->
<header>
<div class="logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<span>图片生成器</span>
</div>
<button class="icon-btn" id="openSettings" title="设置">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</header>
<!-- API Key Alert -->
<div class="alert-banner" id="apiKeyAlert" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>未配置 API Key请先在 <button class="link-btn" id="alertSettingsBtn">设置</button> 中填写。</span>
</div>
<main>
<!-- Model selector -->
<div class="model-tabs">
<button class="model-tab active" data-model="image-01">
<span class="tab-name">image-01</span>
<span class="tab-desc">标准模型</span>
</button>
<button class="model-tab" data-model="image-01-live">
<span class="tab-name">image-01-live</span>
<span class="tab-desc">支持画风</span>
</button>
</div>
<form id="generateForm">
<!-- Prompt -->
<div class="prompt-row">
<textarea
id="promptInput"
placeholder="描述你想要生成的图片..."
rows="4"
maxlength="1500"
autofocus
></textarea>
<div class="prompt-footer">
<span class="char-count" id="charCount">0 / 1500</span>
</div>
</div>
<!-- Options -->
<div class="options-grid">
<!-- Left column -->
<div class="options-col">
<!-- Aspect ratio -->
<div class="opt-group">
<label class="opt-label">比例</label>
<select id="aspectRatio">
<option value="1:1">1:1 — 正方形</option>
<option value="16:9" selected>16:9 — 宽屏</option>
<option value="4:3">4:3 — 标准</option>
<option value="3:2">3:2 — 照片</option>
<option value="2:3">2:3 — 竖图</option>
<option value="3:4">3:4 — 竖图</option>
<option value="9:16">9:16 — 竖屏</option>
<option value="21:9">21:9 — 超宽(仅 image-01</option>
</select>
</div>
<!-- Response format -->
<div class="opt-group">
<label class="opt-label">输出格式</label>
<select id="responseFormat">
<option value="url">URL24小时有效</option>
<option value="base64">Base64直接保存</option>
</select>
</div>
<!-- Count -->
<div class="opt-group">
<label class="opt-label">生成数量 <span class="opt-hint">1-9张</span></label>
<div class="stepper">
<button type="button" class="stepper-btn" id="nMinus"></button>
<input type="number" id="nInput" value="1" min="1" max="9" readonly />
<button type="button" class="stepper-btn" id="nPlus">+</button>
</div>
</div>
<!-- Seed -->
<div class="opt-group">
<label class="opt-label">随机种子 <span class="opt-hint">(留空则随机)</span></label>
<input type="number" id="seedInput" placeholder="输入整数用于复现" min="0" step="1" />
</div>
</div>
<!-- Right column -->
<div class="options-col">
<!-- image-01: custom dimensions -->
<div class="opt-group dimension-group" id="dimensionGroup">
<label class="opt-label">自定义分辨率 <span class="opt-hint">512-2048必须是8的倍数</span></label>
<div class="dim-row">
<input type="number" id="widthInput" placeholder="宽度" min="512" max="2048" step="8" />
<span class="dim-x">×</span>
<input type="number" id="heightInput" placeholder="高度" min="512" max="2048" step="8" />
</div>
<p class="opt-note">同时设置宽高时优先于比例</p>
</div>
<!-- image-01-live: style -->
<div class="opt-group style-group" id="styleGroup" style="display:none">
<label class="opt-label">画风类型</label>
<select id="styleType">
<option value="漫画">漫画</option>
<option value="元气">元气</option>
<option value="中世纪">中世纪</option>
<option value="水彩">水彩</option>
</select>
<label class="opt-label" style="margin-top:10px">画风权重 <span class="opt-hint" id="weightHint">0.8</span></label>
<input type="range" id="styleWeight" min="1" max="10" value="8" step="1" class="weight-slider" />
</div>
<!-- Toggles -->
<div class="toggle-group">
<label class="toggle-row">
<input type="checkbox" id="promptOptimizer" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">自动优化 Prompt</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="watermark" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">添加 AI 水印</span>
</label>
</div>
</div>
</div><!-- /options-grid -->
<!-- Generate -->
<button type="submit" class="generate-btn" id="generateBtn">
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10 13,2"/>
</svg>
<span class="btn-text">生成图片</span>
<svg class="spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="20"/>
</svg>
</button>
</form>
<!-- Error -->
<div class="error-msg" id="errorMsg" style="display:none"></div>
<!-- Results -->
<div id="resultArea" style="display:none">
<div class="result-meta" id="resultMeta"></div>
<div class="image-grid" id="imageGrid"></div>
</div>
</main>
</div>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>设置</h2>
<button class="icon-btn" id="closeSettings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="field">
<label for="apiKeyInput">MiniMax API Key</label>
<div class="input-row">
<input type="password" id="apiKeyInput" placeholder="eyJhbGciOiJIUzI1NiIsInR..." />
<button class="icon-btn" id="toggleKey" type="button" title="显示/隐藏">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<p class="field-hint">请从 <a href="https://platform.minimaxi.com/user-center/basic-information/interface-key" target="_blank" rel="noopener">platform.minimaxi.com</a> 获取</p>
</div>
<div class="field">
<label for="baseUrlInput">API 地址</label>
<input type="text" id="baseUrlInput" placeholder="https://api.minimaxi.com" />
<p class="field-hint">默认使用中国区 API仅在需要代理时修改。</p>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" id="cancelSettings">取消</button>
<button class="btn-primary" id="saveSettingsBtn">保存</button>
</div>
</div>
</div>
<script>
'use strict';
// ── State ──────────────────────────────────────────────────────
let currentModel = 'image-01';
let currentImages = []; // [{url, base64}]
let loading = false;
// ── DOM refs ───────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const promptInput = $('promptInput');
const charCount = $('charCount');
const generateForm = $('generateForm');
const generateBtn = $('generateBtn');
const btnIcon = generateBtn.querySelector('.btn-icon');
const btnText = generateBtn.querySelector('.btn-text');
const spinner = generateBtn.querySelector('.spinner');
const errorMsg = $('errorMsg');
const resultArea = $('resultArea');
const resultMeta = $('resultMeta');
const imageGrid = $('imageGrid');
const apiKeyAlert = $('apiKeyAlert');
const alertSettings = $('alertSettingsBtn');
const aspectRatio = $('aspectRatio');
const responseFormat = $('responseFormat');
const nInput = $('nInput');
const nMinus = $('nMinus');
const nPlus = $('nPlus');
const seedInput = $('seedInput');
const widthInput = $('widthInput');
const heightInput = $('heightInput');
const styleGroup = $('styleGroup');
const styleType = $('styleType');
const styleWeight = $('styleWeight');
const weightHint = $('weightHint');
const promptOptimizer= $('promptOptimizer');
const watermark = $('watermark');
const settingsModal = $('settingsModal');
const openSettings = $('openSettings');
const closeSettings = $('closeSettings');
const cancelSettings = $('cancelSettings');
const saveSettingsBtn= $('saveSettingsBtn');
const apiKeyInput = $('apiKeyInput');
const toggleKey = $('toggleKey');
const baseUrlInput = $('baseUrlInput');
// ── Model tabs ─────────────────────────────────────────────────
document.querySelectorAll('.model-tab').forEach(tab => {
tab.addEventListener('click', () => {
const model = tab.dataset.model;
setModel(model);
});
});
function setModel(model) {
currentModel = model;
document.querySelectorAll('.model-tab').forEach(t =>
t.classList.toggle('active', t.dataset.model === model)
);
const isLive = model === 'image-01-live';
styleGroup.style.display = isLive ? '' : 'none';
// 21:9 only for image-01
const opt21_9 = aspectRatio.querySelector('option[value="21:9"]');
if (opt21_9) opt21_9.disabled = isLive;
if (isLive && aspectRatio.value === '21:9') aspectRatio.value = '16:9';
}
// ── Stepper (n) ────────────────────────────────────────────────
nMinus.addEventListener('click', () => {
const v = parseInt(nInput.value) - 1;
if (v >= 1) nInput.value = v;
});
nPlus.addEventListener('click', () => {
const v = parseInt(nInput.value) + 1;
if (v <= 9) nInput.value = v;
});
// ── Weight slider ──────────────────────────────────────────────
styleWeight.addEventListener('input', () => {
weightHint.textContent = (styleWeight.value / 10).toFixed(1);
});
// ── Helpers ────────────────────────────────────────────────────
function setLoading(on) {
loading = on;
generateBtn.disabled = on;
btnIcon.style.display = on ? 'none' : '';
btnText.style.display = on ? 'none' : '';
spinner.style.display = on ? 'inline' : 'none';
generateBtn.classList.toggle('loading', on);
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = 'flex';
errorMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearError() {
errorMsg.style.display = 'none';
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
function buildPayload() {
const n = parseInt(nInput.value);
const seed = seedInput.value ? parseInt(seedInput.value) : undefined;
const width = widthInput.value ? parseInt(widthInput.value) : undefined;
const height = heightInput.value ? parseInt(heightInput.value) : undefined;
const payload = {
model: currentModel,
prompt: promptInput.value.trim(),
aspect_ratio: aspectRatio.value,
response_format: responseFormat.value,
n: n,
};
if (seed) payload.seed = seed;
if (width && height) { payload.width = width; payload.height = height; }
if (promptOptimizer.checked) payload.prompt_optimizer = true;
if (watermark.checked) payload.aigc_watermark = true;
if (currentModel === 'image-01-live') {
payload.style = {
style_type: styleType.value,
style_weight: parseFloat((styleWeight.value / 10).toFixed(1)),
};
}
return payload;
}
function renderResults(images, id, successCount, failedCount) {
currentImages = images;
imageGrid.innerHTML = '';
if (id) {
resultMeta.innerHTML = `
<span>任务 ID<code>${id}</code></span>
${successCount != null ? `<span>成功 ${successCount}${failedCount ? `,失败 ${failedCount}` : ''}</span>` : ''}
`;
resultMeta.style.display = 'flex';
} else {
resultMeta.style.display = 'none';
}
const fmt = responseFormat.value;
images.forEach((src, i) => {
const card = document.createElement('div');
card.className = 'image-card';
const img = document.createElement('img');
img.src = fmt === 'url' ? src : `data:image/jpeg;base64,${src}`;
img.alt = `生成结果 ${i + 1}`;
img.loading = 'lazy';
const actions = document.createElement('div');
actions.className = 'image-actions';
const downloadBtn = document.createElement('button');
downloadBtn.className = 'action-btn';
downloadBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>下载`;
downloadBtn.addEventListener('click', () => downloadImage(src, fmt, i));
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn';
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>复制`;
copyBtn.addEventListener('click', () => copySrc(src, fmt, copyBtn));
actions.appendChild(downloadBtn);
actions.appendChild(copyBtn);
card.appendChild(img);
card.appendChild(actions);
imageGrid.appendChild(card);
});
resultArea.style.display = 'block';
resultArea.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function downloadImage(src, fmt, index) {
const a = document.createElement('a');
if (fmt === 'url') {
a.href = src;
} else {
a.href = `data:image/jpeg;base64,${src}`;
}
a.download = `生成-${Date.now()}-${index + 1}.jpg`;
a.target = '_blank';
a.click();
}
async function copySrc(src, fmt, btn) {
try {
if (fmt === 'url') {
await navigator.clipboard.writeText(src);
} else {
await navigator.clipboard.writeText(src);
}
const orig = btn.innerHTML;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20,6 9,17 4,12"/></svg>已复制`;
setTimeout(() => { btn.innerHTML = orig; }, 1500);
} catch {
showError('复制失败');
}
}
// ── Init ────────────────────────────────────────────────────────
async function init() {
try {
const cfg = await api('/api/config');
if (!cfg.hasApiKey) apiKeyAlert.style.display = 'flex';
baseUrlInput.value = cfg.baseUrl || 'https://api.minimaxi.com';
} catch { /* non-fatal */ }
}
// ── Generate ────────────────────────────────────────────────────
generateForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (loading) return;
const prompt = promptInput.value.trim();
if (!prompt) { showError('请输入图片描述'); return; }
clearError();
resultArea.style.display = 'none';
setLoading(true);
try {
const payload = buildPayload();
const data = await api('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const images = data.images || [];
if (images.length === 0) {
throw new Error('未返回任何图片');
}
renderResults(images, data.id, data.success_count, data.failed_count);
} catch (err) {
showError(err.message);
} finally {
setLoading(false);
}
});
// ── Prompt char count ──────────────────────────────────────────
promptInput.addEventListener('input', () => {
charCount.textContent = `${promptInput.value.length} / 1500`;
});
// ── Settings modal ──────────────────────────────────────────────
function openModal() {
settingsModal.classList.add('open');
apiKeyInput.focus();
}
function closeModal() {
settingsModal.classList.remove('open');
clearError();
}
openSettings.addEventListener('click', openModal);
alertSettings.addEventListener('click', openModal);
closeSettings.addEventListener('click', closeModal);
cancelSettings.addEventListener('click', closeModal);
settingsModal.addEventListener('click', e => { if (e.target === settingsModal) closeModal(); });
toggleKey.addEventListener('click', () => {
apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password';
});
saveSettingsBtn.addEventListener('click', async () => {
clearError();
const apiKey = apiKeyInput.value.trim();
const baseUrl = baseUrlInput.value.trim() || 'https://api.minimaxi.com';
try {
await api('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey, baseUrl }),
});
if (apiKey) apiKeyAlert.style.display = 'none';
closeModal();
} catch (err) {
showError(err.message);
}
});
// ── Start ──────────────────────────────────────────────────────
init();
</script>
</body>
</html>