chutes / public /index.html
Logankunfall's picture
pr3 (#3)
86bd8c5 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chutes 图像生成</title>
<style>
:root {
--bg: #f8fafc;
--fg: #1e293b;
--card: #ffffff;
--muted: #64748b;
--accent: #6366f1;
--accent-hover: #4f46e5;
--border: #e2e8f0;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] {
--bg: #0f0f23;
--fg: #e2e8f0;
--card: #1a1a2e;
--muted: #94a3b8;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--border: #2d2d44;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
line-height: 1.5;
}
/* Header Styles */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
background: var(--card);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
box-shadow: var(--shadow);
}
header h1 {
font-size: 14px;
margin: 0;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background-clip: text;
flex-shrink: 0;
}
.controls {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.toggle-group {
display: flex;
gap: 4px;
}
.toggle-btn {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--fg);
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
user-select: none;
min-height: 28px;
}
.toggle-btn:hover {
background: var(--accent);
color: white;
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.toggle-btn.active {
background: var(--accent);
color: white;
box-shadow: var(--shadow);
}
.api-input {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
font-size: 11px;
transition: all 0.2s ease;
}
.api-input:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.api-input input {
border: none;
background: transparent;
color: var(--fg);
width: 140px;
padding: 2px;
font-size: 11px;
}
.api-input input:focus { outline: none; }
/* Mobile Menu Toggle */
.mobile-menu-toggle {
display: none;
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
min-height: 28px;
}
.mobile-menu-toggle:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Main Layout */
main {
display: grid;
grid-template-columns: 320px 1fr;
gap: 16px;
padding: 16px;
min-height: calc(100vh - 80px);
}
/* Panel Styles */
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.panel.results {
overflow: auto;
max-height: calc(100vh - 120px);
}
.panel:hover {
box-shadow: var(--shadow-lg);
}
/* Form Styles */
.group {
margin-bottom: 16px;
}
.group label {
display: block;
font-size: 14px;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
letter-spacing: 0.025em;
}
.group input, .group select, .group textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-size: 14px;
transition: all 0.2s ease;
font-family: inherit;
}
.group input:focus, .group select:focus, .group textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
transform: translateY(-1px);
}
.group textarea {
resize: vertical;
min-height: 80px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
/* Button Styles */
button {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: white;
border: none;
border-radius: 6px;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
user-select: none;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
}
button.secondary:hover {
background: var(--border);
border-color: var(--accent);
}
.seed-toggle {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.seed-toggle.active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: var(--shadow);
}
.seed-toggle:hover {
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.status {
font-size: 14px;
color: var(--muted);
line-height: 1.4;
}
/* Gallery Styles */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: var(--shadow);
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.card img {
width: 100%;
height: auto;
display: block;
aspect-ratio: 1;
object-fit: cover;
cursor: pointer;
transition: opacity 0.2s ease;
}
.card img:hover {
opacity: 0.9;
}
.card .meta {
padding: 16px;
border-top: 1px solid var(--border);
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
color: var(--muted);
font-size: 14px;
background: var(--bg);
border-radius: 6px;
margin: 8px;
}
/* Sidebar for mobile */
.sidebar {
position: fixed;
top: 0;
left: -100%;
width: 320px;
height: 100vh;
background: var(--card);
border-right: 1px solid var(--border);
z-index: 200;
transition: left 0.3s ease;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.sidebar.open {
left: 0;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-close {
background: none;
border: none;
color: var(--fg);
font-size: 20px;
cursor: pointer;
padding: 4px;
border-radius: 6px;
min-height: auto;
}
.sidebar-close:hover {
background: var(--border);
transform: none;
box-shadow: none;
}
.sidebar-content {
padding: 16px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 150;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
pointer-events: none;
}
.overlay.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
/* Mobile Generate Button - Draggable */
.mobile-generate-btn {
position: fixed;
right: 16px;
bottom: 80px;
z-index: 120;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
font-size: 24px;
cursor: move;
box-shadow: var(--shadow-lg);
transition: all 0.3s ease;
display: none;
touch-action: none;
user-select: none;
}
.mobile-generate-btn:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
}
.mobile-generate-btn:active {
transform: scale(0.95);
}
.mobile-generate-btn.dragging {
transition: none;
z-index: 1000;
transform: scale(1.1);
}
/* Card Meta Expandable - Fixed for mobile */
.card .meta {
padding: 16px;
border-top: 1px solid var(--border);
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.card .meta-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card .meta-toggle {
background: var(--bg);
border: 1px solid var(--border);
color: var(--accent);
cursor: pointer;
font-size: 12px;
padding: 6px 12px;
border-radius: 4px;
transition: all 0.2s ease;
min-height: 32px;
font-weight: 500;
touch-action: manipulation;
}
.card .meta-toggle:hover,
.card .meta-toggle:active {
background: var(--accent);
color: white;
border-color: var(--accent);
transform: none;
box-shadow: none;
}
.card .meta-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card .meta-content.expanded {
max-height: 300px;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
header {
padding: 4px 8px;
gap: 6px;
}
header h1 {
font-size: 12px;
flex: 1;
text-align: center;
padding: 2px 6px;
}
.controls {
display: none;
}
.mobile-menu-toggle {
display: block;
order: 1;
}
.mobile-generate-btn {
display: block;
}
main {
grid-template-columns: 1fr;
gap: 12px;
padding: 12px;
min-height: auto;
}
/* Hide desktop panel on mobile */
#desktopPanel {
display: none;
}
.panel.results {
padding: 16px;
border-radius: 8px;
}
.gallery {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.card .meta {
padding: 12px;
font-size: 12px;
}
button, .seed-toggle {
min-height: 48px;
font-size: 16px;
}
.group input, .group select, .group textarea {
padding: 14px 16px;
font-size: 16px;
border-radius: 6px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
touch-action: manipulation;
}
/* 修复iOS输入框问题 */
.group input:focus, .group select:focus, .group textarea:focus {
transform: none;
-webkit-user-select: text;
user-select: text;
}
.group textarea {
-webkit-user-select: text;
user-select: text;
resize: vertical;
}
}
@media (max-width: 480px) {
header {
padding: 8px 12px;
}
main {
padding: 8px;
gap: 8px;
}
.panel {
padding: 12px;
}
.gallery {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.controls {
flex-direction: column;
gap: 8px;
}
.toggle-btn {
width: 100%;
}
}
/* SVG图标样式 */
.icon-sparkles {
width: 24px;
height: 24px;
fill: currentColor;
}
/* 大图预览模态框样式 */
.image-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 20px;
}
.image-modal.show {
display: flex;
}
.image-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 32px;
cursor: pointer;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10001;
min-height: auto;
padding: 0;
}
.image-modal-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.image-modal-content {
max-width: 90%;
max-height: 85%;
display: flex;
align-items: center;
justify-content: center;
}
.image-modal-content img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.image-modal-info {
color: white;
margin-top: 16px;
font-size: 14px;
text-align: center;
background: rgba(0, 0, 0, 0.5);
padding: 8px 16px;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- SVG图标定义 -->
<svg style="display: none;">
<defs>
<symbol id="icon-sparkles" viewBox="0 0 24 24">
<path d="M12 0L14.59 8.41L23 11L14.59 13.59L12 22L9.41 13.59L1 11L9.41 8.41L12 0Z"/>
<path d="M19 8L20.5 12.5L25 14L20.5 15.5L19 20L17.5 15.5L13 14L17.5 12.5L19 8Z" opacity="0.6"/>
</symbol>
<symbol id="icon-bell" viewBox="0 0 24 24">
<path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z"/>
</symbol>
<symbol id="icon-bell-off" viewBox="0 0 24 24">
<path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z" opacity="0.3"/>
<path d="M2 2L22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
</defs>
</svg>
<header>
<button class="mobile-menu-toggle" id="mobileMenuToggle"></button>
<h1>Chutes</h1>
<div class="controls">
<div class="toggle-group">
<button id="soundToggle" class="toggle-btn">
<svg width="16" height="16">
<use href="#icon-bell"></use>
</svg>
</button>
<button id="themeToggle" class="toggle-btn">主题</button>
</div>
<div class="api-input">
<span>密钥</span>
<input type="password" id="apiKeyInput" placeholder="API 密钥">
</div>
</div>
</header>
<!-- Mobile Sidebar -->
<div class="overlay" id="overlay"></div>
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<h3 style="margin: 0; font-size: 16px;">参数设置</h3>
<button class="sidebar-close" id="sidebarClose"></button>
</div>
<div class="sidebar-content">
<div class="group">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<button id="soundToggleMobile" class="toggle-btn">
<svg width="16" height="16" style="margin-right: 4px;">
<use href="#icon-bell"></use>
</svg>
<span>提示音</span>
</button>
<button id="themeToggleMobile" class="toggle-btn">主题</button>
</div>
</div>
<div class="group">
<label>API 密钥</label>
<input type="password" id="apiKeyInputMobile" placeholder="粘贴密钥" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 14px;">
</div>
<div class="group">
<label>模型</label>
<select id="modelSelectMobile"></select>
</div>
<div class="group">
<label>提示词</label>
<textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
</div>
<div class="group" id="groupImagesMobile">
<label>参考图(最多3张,仅用于 qwen-image-edit)</label>
<input type="file" id="imagesMobile" accept="image/*" multiple>
<div class="status" id="imagesStatusMobile">未选择</div>
</div>
<div class="group" id="groupTrueCfgMobile">
<label>True CFG 系数(qwen-image-edit)</label>
<input type="number" id="true_cfg_scaleMobile" step="0.1" min="0" max="10" value="4">
</div>
<div class="group">
<label>反向提示词</label>
<textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
</div>
<div class="group" id="groupResolutionMobile" style="display: none;">
<label>分辨率(仅 hidream)</label>
<select id="resolutionMobile">
<option value="1024x1024">1024x1024</option>
<option value="768x1360">768x1360</option>
<option value="1360x768">1360x768</option>
<option value="880x1168">880x1168</option>
<option value="1168x880">1168x880</option>
<option value="1248x832">1248x832</option>
<option value="832x1248">832x1248</option>
</select>
</div>
<div class="group form-grid" id="groupZImageParamsMobile" style="display: none;">
<div class="group">
<label>Shift(Z-Image-Turbo)</label>
<input type="number" id="shiftMobile" step="0.1" min="1" max="10" value="3.0">
</div>
<div class="group">
<label>Max Sequence Length</label>
<input type="number" id="max_sequence_lengthMobile" step="1" min="256" max="2048" value="512">
</div>
</div>
<div class="group form-grid">
<div class="group" id="groupWidthMobile">
<label>宽度</label>
<input type="number" id="widthMobile" value="1024">
</div>
<div class="group" id="groupHeightMobile">
<label>高度</label>
<input type="number" id="heightMobile" value="1024">
</div>
<div class="group">
<label>指导系数</label>
<input type="number" id="guidance_scaleMobile" step="0.1" value="6">
</div>
<div class="group">
<label>推理步数</label>
<input type="number" id="num_inference_stepsMobile" value="20">
</div>
</div>
<div class="group">
<button id="seedToggleMobile" class="seed-toggle">使用随机</button>
</div>
<div class="group">
<label>数量(最多10)</label>
<input type="number" id="batchCountMobile" value="1" placeholder="1-10">
</div>
<div class="group">
<label>保存位置</label>
<button id="chooseFolderMobile" class="secondary" type="button">选择保存文件夹</button>
<div class="status" id="folderStatusMobile" style="margin-top: 8px;">未选择</div>
</div>
<div class="group">
<button id="generateBtnMobile">生成图像</button>
</div>
</div>
</div>
<!-- Mobile Generate Button -->
<button class="mobile-generate-btn" id="mobileGenerateBtn">
<svg class="icon-sparkles">
<use href="#icon-sparkles"></use>
</svg>
</button>
<main>
<section class="panel" id="desktopPanel">
<div class="group">
<button id="generateBtn">生成图像</button>
</div>
<div class="group">
<label>模型</label>
<select id="modelSelect"></select>
</div>
<div class="group">
<label>提示词</label>
<textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
</div>
<div class="group" id="groupImages">
<label>参考图(最多3张,仅用于 qwen-image-edit)</label>
<input type="file" id="images" accept="image/*" multiple>
<div class="status" id="imagesStatus">未选择</div>
</div>
<div class="group" id="groupTrueCfg">
<label>True CFG 系数(qwen-image-edit)</label>
<input type="number" id="true_cfg_scale" step="0.1" min="0" max="10" value="4">
</div>
<div class="group">
<label>反向提示词</label>
<textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
</div>
<div class="group" id="groupResolution" style="display: none;">
<label>分辨率(仅 hidream)</label>
<select id="resolution">
<option value="1024x1024">1024x1024</option>
<option value="768x1360">768x1360</option>
<option value="1360x768">1360x768</option>
<option value="880x1168">880x1168</option>
<option value="1168x880">1168x880</option>
<option value="1248x832">1248x832</option>
<option value="832x1248">832x1248</option>
</select>
</div>
<div class="group form-grid" id="groupZImageParams" style="display: none;">
<div class="group">
<label>Shift(Z-Image-Turbo)</label>
<input type="number" id="shift" step="0.1" min="1" max="10" value="3.0">
</div>
<div class="group">
<label>Max Sequence Length</label>
<input type="number" id="max_sequence_length" step="1" min="256" max="2048" value="512">
</div>
</div>
<div class="group form-grid">
<div class="group" id="groupWidth">
<label>宽度</label>
<input type="number" id="width" value="1024">
</div>
<div class="group" id="groupHeight">
<label>高度</label>
<input type="number" id="height" value="1024">
</div>
<div class="group">
<label>指导系数</label>
<input type="number" id="guidance_scale" step="0.1" value="6">
</div>
<div class="group">
<label>推理步数</label>
<input type="number" id="num_inference_steps" value="20">
</div>
</div>
<div class="group">
<button id="seedToggle" class="seed-toggle">使用随机</button>
</div>
<div class="group form-grid">
<div class="group">
<label>数量(最多10)</label>
<input type="number" id="batchCount" value="1" placeholder="1-10">
</div>
<div class="group">
<label>保存位置</label>
<button id="chooseFolder" class="secondary" type="button">选择保存文件夹</button>
<div class="status" id="folderStatus" style="margin-top: 2px;">未选择</div>
</div>
</div>
</section>
<section class="panel results">
<div class="row" style="justify-content: space-between; margin-bottom: 8px;">
<div class="status">生成结果</div>
<button id="downloadAll" class="secondary">逐个下载</button>
</div>
<div id="gallery" class="gallery"></div>
</section>
</main>
<!-- 大图预览模态框 -->
<div class="image-modal" id="imageModal">
<button class="image-modal-close" id="imageModalClose">×</button>
<div class="image-modal-content">
<img id="imageModalImg" src="" alt="预览">
</div>
<div class="image-modal-info" id="imageModalInfo"></div>
</div>
<audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
<script>
const qs = s => document.querySelector(s);
const qsa = s => Array.from(document.querySelectorAll(s));
const ls = {
get(k, d) { try { const v = localStorage.getItem(k); return v !== null ? JSON.parse(v) : d; } catch(e) { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch(e) {} }
};
const state = {
models: [],
modelsConfig: [], // 存储完整的模型配置(包括 default_params)
sound: true,
theme: 'light',
apiKey: '',
folderHandle: null,
seedRandom: true,
isMobile: window.innerWidth <= 768,
sidebarOpen: false,
imageB64s: []
};
function setTheme(t) {
const theme = t === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
state.theme = theme;
ls.set('theme', theme);
// 更新桌面端按钮
const btn = qs('#themeToggle');
if (btn) {
btn.classList.toggle('active', theme === 'dark');
btn.textContent = theme === 'dark' ? '浅色' : '深色';
}
// 更新移动端按钮
const btnMobile = qs('#themeToggleMobile');
if (btnMobile) {
btnMobile.classList.toggle('active', theme === 'dark');
btnMobile.textContent = theme === 'dark' ? '浅色' : '深色';
}
}
function setSound(on) {
state.sound = !!on;
ls.set('sound', state.sound);
// 更新桌面端按钮
const btn = qs('#soundToggle');
if (btn) {
btn.classList.toggle('active', state.sound);
const svg = btn.querySelector('svg use');
if (svg) {
svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
}
}
// 更新移动端按钮
const btnMobile = qs('#soundToggleMobile');
if (btnMobile) {
btnMobile.classList.toggle('active', state.sound);
const svg = btnMobile.querySelector('svg use');
const span = btnMobile.querySelector('span');
if (svg) {
svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
}
if (span) {
span.textContent = state.sound ? '开启' : '关闭';
}
}
}
function setApiKey(k) {
state.apiKey = (k || '').trim();
ls.set('apiKey', state.apiKey);
const el = qs('#apiKeyInput');
if (el && el.value !== state.apiKey) el.value = state.apiKey;
const elMobile = qs('#apiKeyInputMobile');
if (elMobile && elMobile.value !== state.apiKey) elMobile.value = state.apiKey;
}
function updateSeedToggle() {
const buttons = ['#seedToggle', '#seedToggleMobile'];
buttons.forEach(selector => {
const btn = qs(selector);
if (btn) {
btn.classList.toggle('active', state.seedRandom);
btn.textContent = state.seedRandom ? '使用随机' : '固定种子';
}
});
}
function genRandomSeed() {
return Math.floor(Math.random() * 100000000);
}
async function fetchModels() {
try {
const r = await fetch('/api/models', {
headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
});
const j = await r.json();
// 保存完整的模型配置
state.modelsConfig = j.models || [];
state.models = (j.models || []).map(m => ({
id: String(m.id || m.name || ''),
name: String(m.name || m.id || ''),
default_params: m.default_params || null
}));
renderModels();
} catch(e) {
console.error('获取模型列表失败:', e);
}
}
function renderModels() {
const selectors = ['#modelSelect', '#modelSelectMobile'];
selectors.forEach(selector => {
const sel = qs(selector);
if (sel) {
sel.innerHTML = '';
state.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
sel.appendChild(opt);
});
const last = ls.get('lastParams', null);
if (last && last.model) sel.value = last.model;
}
});
// 根据当前模型更新参数可见性(桌面与移动端)
updateVisibleFieldsFor('');
updateVisibleFieldsFor('Mobile');
}
function toggleGroup(selector, show) {
const el = qs(selector);
if (!el) return;
el.style.display = show ? '' : 'none';
}
function updateVisibleFieldsFor(suffix) {
const sel = qs(`#modelSelect${suffix}`);
if (!sel) return;
const model = String(sel.value || '').toLowerCase();
const isQwenEdit = model === 'qwen-image-edit';
const isHidream = model === 'hidream';
const isZImageTurbo = model === 'z-image-turbo';
toggleGroup(`#groupImages${suffix}`, isQwenEdit);
toggleGroup(`#groupTrueCfg${suffix}`, isQwenEdit);
// hidream 使用分辨率;隐藏宽高,显示分辨率下拉
toggleGroup(`#groupWidth${suffix}`, !isHidream);
toggleGroup(`#groupHeight${suffix}`, !isHidream);
toggleGroup(`#groupResolution${suffix}`, isHidream);
// Z-Image-Turbo 显示专用参数
toggleGroup(`#groupZImageParams${suffix}`, isZImageTurbo);
// 应用模型的默认参数
applyModelDefaultParams(model, suffix);
}
// 应用模型的默认参数
function applyModelDefaultParams(modelId, suffix) {
const modelConfig = state.models.find(m => m.id.toLowerCase() === modelId.toLowerCase());
if (!modelConfig || !modelConfig.default_params) return;
const params = modelConfig.default_params;
// 应用 guidance_scale
if (params.guidance_scale !== undefined) {
const el = qs(`#guidance_scale${suffix}`);
if (el) el.value = params.guidance_scale;
}
// 应用 num_inference_steps
if (params.num_inference_steps !== undefined) {
const el = qs(`#num_inference_steps${suffix}`);
if (el) el.value = params.num_inference_steps;
}
// 应用 shift
if (params.shift !== undefined) {
const el = qs(`#shift${suffix}`);
if (el) el.value = params.shift;
}
// 应用 max_sequence_length
if (params.max_sequence_length !== undefined) {
const el = qs(`#max_sequence_length${suffix}`);
if (el) el.value = params.max_sequence_length;
}
}
function currentParams() {
const isMobile = window.innerWidth <= 768;
const prefix = isMobile ? 'Mobile' : '';
const modelId = qs(`#modelSelect${prefix}`).value || '';
const modelConfig = state.models.find(m => m.id === modelId);
const params = {
model: modelId,
prompt: (qs(`#prompt${prefix}`).value || '').trim(),
negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
width: Number(qs(`#width${prefix}`).value) || 1024,
height: Number(qs(`#height${prefix}`).value) || 1024,
guidance_scale: Number(qs(`#guidance_scale${prefix}`).value),
num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
true_cfg_scale: Number(qs(`#true_cfg_scale${prefix}`)?.value) || 4,
image_b64s: state.imageB64s ? state.imageB64s.slice(0, 3) : [],
seed: state.seedRandom ? null : 0,
resolution: (qs(`#resolution${prefix}`) && qs(`#resolution${prefix}`).value) || ''
};
// Handle guidance_scale default value properly - only use default if value is NaN (empty input)
if (Number.isNaN(params.guidance_scale)) {
params.guidance_scale = 6;
}
// 添加 Z-Image-Turbo 特定参数(从 UI 控件读取)
const shiftEl = qs(`#shift${prefix}`);
const maxSeqEl = qs(`#max_sequence_length${prefix}`);
if (shiftEl && shiftEl.offsetParent !== null) {
params.shift = Number(shiftEl.value) || 3.0;
}
if (maxSeqEl && maxSeqEl.offsetParent !== null) {
params.max_sequence_length = Number(maxSeqEl.value) || 512;
}
return params;
}
// Helper to get display size string (resolution for hidream, width x height for others)
function getDisplaySize(params) {
const isHidream = (params.model || '').toLowerCase() === 'hidream';
if (isHidream && params.resolution) {
return params.resolution;
}
return `${params.width}x${params.height}`;
}
function createPlaceholderCard(i, params) {
const wrap = document.createElement('div');
wrap.className = 'card';
const ph = document.createElement('div');
ph.className = 'placeholder';
ph.textContent = `生成中 #${i}`;
wrap.appendChild(ph);
const meta = document.createElement('div');
meta.className = 'meta';
meta.innerHTML = `${params.model} | ${getDisplaySize(params)}`;
wrap.appendChild(meta);
qs('#gallery').prepend(wrap);
return wrap;
}
function updateCardWithImage(wrap, dataUrl, params, filename) {
wrap.innerHTML = '';
const img = document.createElement('img');
img.src = dataUrl;
img.alt = params.prompt || 'image';
// 添加点击放大功能
img.addEventListener('click', () => {
showImageModal(dataUrl, params);
});
const meta = document.createElement('div');
meta.className = 'meta';
const metaHeader = document.createElement('div');
metaHeader.className = 'meta-header';
metaHeader.innerHTML = `
<div style="font-weight: 600;">${params.model}</div>
`;
const toggleBtn = document.createElement('button');
toggleBtn.className = 'meta-toggle';
toggleBtn.textContent = '详情';
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggleCardMeta(this);
});
toggleBtn.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation();
toggleCardMeta(this);
});
metaHeader.appendChild(toggleBtn);
const metaContent = document.createElement('div');
metaContent.className = 'meta-content';
metaContent.innerHTML = `
<div style="margin-bottom: 4px;">尺寸: ${getDisplaySize(params)}</div>
<div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
<div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
`;
const row = document.createElement('div');
row.className = 'row';
const dlBtn = document.createElement('button');
dlBtn.className = 'secondary';
dlBtn.textContent = '下载';
dlBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
downloadImageMobile(filename, dataUrl);
});
row.appendChild(dlBtn);
metaContent.appendChild(row);
meta.appendChild(metaHeader);
meta.appendChild(metaContent);
wrap.appendChild(img);
wrap.appendChild(meta);
}
// 切换卡片详情显示 - 修复移动端点击问题
function toggleCardMeta(button) {
const metaContent = button.parentElement.nextElementSibling;
const isExpanded = metaContent.classList.contains('expanded');
if (isExpanded) {
metaContent.classList.remove('expanded');
button.textContent = '详情';
} else {
metaContent.classList.add('expanded');
button.textContent = '收起';
}
}
// Image Viewer (Lightbox)
function ensureImageViewer() {
let overlay = document.getElementById('imageViewer');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'imageViewer';
overlay.className = 'image-viewer-overlay';
const content = document.createElement('div');
content.className = 'image-viewer-content';
const img = document.createElement('img');
img.alt = 'preview';
const closeBtn = document.createElement('button');
closeBtn.className = 'image-viewer-close';
closeBtn.textContent = '关闭';
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
closeImageViewer();
});
// click outside content area closes viewer
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeImageViewer();
}
});
content.appendChild(img);
content.appendChild(closeBtn);
overlay.appendChild(content);
document.body.appendChild(overlay);
}
return overlay;
}
function openImageViewer(dataUrl, params) {
const overlay = ensureImageViewer();
const img = overlay.querySelector('.image-viewer-content img');
img.src = dataUrl;
img.alt = (params && params.prompt) ? params.prompt : 'image';
overlay.classList.add('show');
}
function closeImageViewer() {
const overlay = document.getElementById('imageViewer');
if (overlay) {
overlay.classList.remove('show');
const img = overlay.querySelector('.image-viewer-content img');
if (img) img.src = '';
}
}
function isViewerOpen() {
const overlay = document.getElementById('imageViewer');
return !!(overlay && overlay.classList.contains('show'));
}
// Image Viewer (Lightbox)
function ensureImageViewer() {
let overlay = document.getElementById('imageViewer');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'imageViewer';
overlay.className = 'image-viewer-overlay';
const content = document.createElement('div');
content.className = 'image-viewer-content';
const img = document.createElement('img');
img.alt = 'preview';
const closeBtn = document.createElement('button');
closeBtn.className = 'image-viewer-close';
closeBtn.textContent = '关闭';
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
closeImageViewer();
});
// click outside content area closes viewer
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeImageViewer();
}
});
content.appendChild(img);
content.appendChild(closeBtn);
overlay.appendChild(content);
document.body.appendChild(overlay);
}
return overlay;
}
function openImageViewer(dataUrl, params) {
const overlay = ensureImageViewer();
const img = overlay.querySelector('.image-viewer-content img');
img.src = dataUrl;
img.alt = (params && params.prompt) ? params.prompt : 'image';
overlay.classList.add('show');
}
function closeImageViewer() {
const overlay = document.getElementById('imageViewer');
if (overlay) {
overlay.classList.remove('show');
const img = overlay.querySelector('.image-viewer-content img');
if (img) img.src = '';
}
}
function isViewerOpen() {
const overlay = document.getElementById('imageViewer');
return !!(overlay && overlay.classList.contains('show'));
}
// 拖拽功能
let dragState = {
isDragging: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
initialX: 0,
initialY: 0
};
function initDragButton() {
const btn = qs('#mobileGenerateBtn');
if (!btn) return;
// 鼠标事件
btn.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', endDrag);
// 触摸事件
btn.addEventListener('touchstart', startDrag, { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', endDrag);
document.addEventListener('touchcancel', endDrag, { passive: false });
btn.addEventListener('touchcancel', endDrag);
}
function startDrag(e) {
const btn = qs('#mobileGenerateBtn');
if (!btn) return;
dragState.isDragging = true;
btn.classList.add('dragging');
if (e.type === 'touchstart') {
dragState.startX = e.touches[0].clientX;
dragState.startY = e.touches[0].clientY;
} else {
dragState.startX = e.clientX;
dragState.startY = e.clientY;
}
const rect = btn.getBoundingClientRect();
dragState.initialX = rect.left;
dragState.initialY = rect.top;
dragState.currentX = rect.left;
dragState.currentY = rect.top;
e.preventDefault();
}
function drag(e) {
if (!dragState.isDragging) return;
e.preventDefault();
let clientX, clientY;
if (e.type === 'touchmove') {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
dragState.currentX = dragState.initialX + (clientX - dragState.startX);
dragState.currentY = dragState.initialY + (clientY - dragState.startY);
// 限制在屏幕范围内
const btn = qs('#mobileGenerateBtn');
const maxX = window.innerWidth - 60;
const maxY = window.innerHeight - 60;
const headerH = (qs('header') && qs('header').offsetHeight) || 0;
const minY = headerH + 8;
dragState.currentX = Math.max(0, Math.min(maxX, dragState.currentX));
dragState.currentY = Math.max(minY, Math.min(maxY, dragState.currentY));
btn.style.left = dragState.currentX + 'px';
btn.style.top = dragState.currentY + 'px';
btn.style.transform = 'none';
}
function endDrag(e) {
const btn = qs('#mobileGenerateBtn');
if (!btn) return;
const wasDragging = dragState.isDragging;
// 立即清除拖拽状态
dragState.isDragging = false;
btn.classList.remove('dragging');
if (!wasDragging) return;
// 如果移动距离很小,视为点击
const moveDistance = Math.sqrt(
Math.pow(dragState.currentX - dragState.initialX, 2) +
Math.pow(dragState.currentY - dragState.initialY, 2)
);
if (moveDistance < 10) {
// 触发生成
setTimeout(() => generate(), 50);
}
// 保存位置
ls.set('generateBtnPosition', {
x: dragState.currentX || dragState.initialX,
y: dragState.currentY || dragState.initialY
});
}
// 恢复按钮位置
function restoreButtonPosition() {
const btn = qs('#mobileGenerateBtn');
if (!btn) return;
const savedPos = ls.get('generateBtnPosition', null);
const headerH = (qs('header') && qs('header').offsetHeight) || 0;
const minY = headerH + 8;
const maxX = window.innerWidth - 60;
const maxY = window.innerHeight - 60;
let x, y;
if (savedPos) {
x = Math.max(0, Math.min(maxX, savedPos.x));
y = Math.max(minY, Math.min(maxY, savedPos.y));
} else {
// 默认位置:右下角
x = window.innerWidth - 72;
y = window.innerHeight - 136;
}
btn.style.left = x + 'px';
btn.style.top = y + 'px';
btn.style.transform = 'none';
}
// 移动端优化的下载函数
function downloadImageMobile(filename, dataUrl) {
// 检测是否为移动端
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
// 移动端直接触发下载
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 显示提示
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--accent);
color: white;
padding: 12px 20px;
border-radius: 8px;
z-index: 1000;
font-size: 14px;
`;
toast.textContent = '图片已开始下载';
document.body.appendChild(toast);
setTimeout(() => {
document.body.removeChild(toast);
}, 2000);
} else {
// 桌面端使用原有逻辑
downloadImage(filename, dataUrl);
}
}
function removeCard(wrap) {
try { wrap.remove(); } catch(e) {}
}
function dataURLtoBlob(dataUrl) {
const arr = dataUrl.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8 = new Uint8Array(n);
while(n--) { u8[n] = bstr.charCodeAt(n); }
return new Blob([u8], { type: mime });
}
// 将文件读取为去掉 data: 前缀的纯 base64,限制最多3张
async function filesToBase64Raw(fileList) {
const files = Array.from(fileList).slice(0, 3);
const arr = [];
for (const f of files) {
const b64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const s = String(reader.result || '');
const idx = s.indexOf(',');
resolve(idx >= 0 ? s.slice(idx + 1) : s);
};
reader.onerror = () => reject(reader.error || new Error('read failed'));
reader.readAsDataURL(f);
});
arr.push(b64);
}
return arr;
}
function handleImagesChange(inputEl, statusElId) {
try {
const files = inputEl && inputEl.files ? inputEl.files : [];
if (!files || files.length === 0) {
state.imageB64s = [];
const st = qs('#' + statusElId);
if (st) st.textContent = '未选择';
return;
}
filesToBase64Raw(files).then(list => {
state.imageB64s = list;
const st = qs('#' + statusElId);
if (st) st.textContent = '已选择 ' + list.length + ' 张';
}).catch(() => {});
} catch(e) {}
}
async function saveToChosenFolder(filename, dataUrl) {
if (!state.folderHandle) return false;
try {
// 如果无写入权限,避免在非用户激活上下文触发授权提示
const perm = await state.folderHandle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted') return false;
const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(dataURLtoBlob(dataUrl));
await writable.close();
return true;
} catch(e) {
console.warn('保存失败:', e);
return false;
}
}
function downloadImage(filename, dataUrl) {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
async function chooseFolder() {
try {
if (!('showDirectoryPicker' in window)) {
qs('#folderStatus').textContent = '浏览器不支持';
qs('#folderStatusMobile').textContent = '浏览器不支持';
return;
}
const handle = await window.showDirectoryPicker();
// 在用户点击的上下文中请求写入权限
let perm = 'prompt';
if ('queryPermission' in handle && 'requestPermission' in handle) {
perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted') {
perm = await handle.requestPermission({ mode: 'readwrite' });
}
}
state.folderHandle = handle;
const ok = perm === 'granted';
qs('#folderStatus').textContent = ok ? '已选择(可写)' : '已选择(只读)';
qs('#folderStatusMobile').textContent = ok ? '已选择(可写)' : '已选择(只读)';
} catch(e) {
qs('#folderStatus').textContent = '未选择';
qs('#folderStatusMobile').textContent = '未选择';
}
}
// 移动端侧边栏控制
function toggleSidebar() {
state.sidebarOpen = !state.sidebarOpen;
const sidebar = qs('#sidebar');
const overlay = qs('#overlay');
if (state.sidebarOpen) {
sidebar.classList.add('open');
overlay.classList.add('show');
syncFormData('desktop', 'mobile');
} else {
sidebar.classList.remove('open');
overlay.classList.remove('show');
syncFormData('mobile', 'desktop');
}
}
function closeSidebar() {
state.sidebarOpen = false;
qs('#sidebar').classList.remove('open');
qs('#overlay').classList.remove('show');
syncFormData('mobile', 'desktop');
}
// 同步表单数据
function syncFormData(from, to) {
const fromPrefix = from === 'mobile' ? 'Mobile' : '';
const toPrefix = to === 'mobile' ? 'Mobile' : '';
const fields = ['modelSelect', 'prompt', 'negative_prompt', 'width', 'height', 'guidance_scale', 'num_inference_steps', 'batchCount'];
fields.forEach(field => {
const fromEl = qs(`#${field}${fromPrefix}`);
const toEl = qs(`#${field}${toPrefix}`);
if (fromEl && toEl && fromEl.value !== toEl.value) {
toEl.value = fromEl.value;
}
});
}
// 实时同步移动端输入到桌面端
function setupRealtimeSync() {
const fields = ['modelSelect', 'prompt', 'negative_prompt', 'width', 'height', 'guidance_scale', 'num_inference_steps', 'batchCount'];
fields.forEach(field => {
const mobileEl = qs(`#${field}Mobile`);
const desktopEl = qs(`#${field}`);
if (mobileEl && desktopEl) {
// 添加输入事件监听
const eventType = mobileEl.tagName === 'SELECT' ? 'change' : 'input';
mobileEl.addEventListener(eventType, () => {
desktopEl.value = mobileEl.value;
});
}
});
}
// 响应式检测
function checkMobile() {
const wasMobile = state.isMobile;
state.isMobile = window.innerWidth <= 768;
if (wasMobile !== state.isMobile && state.sidebarOpen) {
closeSidebar();
}
// Clamp floating button inside viewport and below header when geometry changes
const btn = qs('#mobileGenerateBtn');
if (btn) {
const rect = btn.getBoundingClientRect();
const maxX = window.innerWidth - 60;
const maxY = window.innerHeight - 60;
const headerH = (qs('header') && qs('header').offsetHeight) || 0;
const minY = headerH + 8;
let x = rect.left;
let y = rect.top;
x = Math.max(0, Math.min(maxX, x));
y = Math.max(minY, Math.min(maxY, y));
btn.style.left = x + 'px';
btn.style.top = y + 'px';
btn.style.transform = 'none';
}
}
async function generate() {
const p = currentParams();
if (!p.model || !p.prompt) {
alert('模型与提示词必填');
return;
}
// qwen-image-edit 需要至少一张参考图
if ((p.model || '').toLowerCase() === 'qwen-image-edit' && (!state.imageB64s || state.imageB64s.length < 1)) {
alert('qwen-image-edit 需要至少 1 张参考图');
return;
}
const isMobile = window.innerWidth <= 768;
const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
const countRaw = Number(batchElement.value || 1);
const count = Math.max(1, Math.min(10, Number.isFinite(countRaw) ? Math.trunc(countRaw) : 1));
const headers = {
'Content-Type': 'application/json',
...(state.apiKey ? { 'x-api-key': state.apiKey } : {})
};
const tasks = [];
for (let i = 1; i <= count; i++) {
const placeholder = createPlaceholderCard(i, p);
const isQwenEdit = (p.model || '').toLowerCase() === 'qwen-image-edit';
const argsBase = {
prompt: p.prompt,
negative_prompt: p.negative_prompt || '',
guidance_scale: p.guidance_scale,
num_inference_steps: p.num_inference_steps,
seed: state.seedRandom ? genRandomSeed() : 0
};
const args = isQwenEdit
? {
...argsBase,
width: p.width,
height: p.height,
true_cfg_scale: Number(qs(`#true_cfg_scale${isMobile ? 'Mobile' : ''}`)?.value) || 4,
...(state.imageB64s && state.imageB64s.length ? { image_b64s: state.imageB64s.slice(0, 3) } : {})
}
: {
...argsBase,
width: p.width,
height: p.height
};
// hidream 期望使用 resolution 字段
if ((p.model || '').toLowerCase() === 'hidream') {
args.resolution = (p.resolution && typeof p.resolution === 'string' && p.resolution.includes('x'))
? p.resolution
: `${p.width}x${p.height}`;
delete args.width;
delete args.height;
}
const body = { model: p.model, input_args: args };
const task = (async () => {
try {
const r = await fetch('/api/generate', {
method: 'POST',
headers,
body: JSON.stringify(body)
});
const jr = await r.json().catch(() => ({ ok: false, error: '响应解析失败' }));
if (!jr.ok) {
const msg = (jr && jr.error) ? String(jr.error) : '生成失败';
if (!/timeout of \d+ms exceeded/i.test(msg)) {
console.error('生成失败:', msg);
}
removeCard(placeholder);
return;
}
const dataUrl = jr.image;
const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
updateCardWithImage(placeholder, dataUrl, p, fname);
let saved = false;
if (state.folderHandle) {
try {
saved = await saveToChosenFolder(fname, dataUrl);
} catch(e) {
saved = false;
}
}
if (!saved) {
downloadImageMobile(fname, dataUrl);
}
if (state.sound) {
const audio = qs('#notify');
if (audio) {
audio.currentTime = 0;
audio.play().catch(() => {});
}
}
} catch(e) {
const msg = String(e && e.message || e || '请求失败');
if (!/timeout of \d+ms exceeded/i.test(msg)) {
console.error('请求失败:', msg);
}
removeCard(placeholder);
}
})();
tasks.push(task);
}
try {
await Promise.allSettled(tasks);
ls.set('lastParams', p);
} catch(e) {
console.error('批量生成失败:', e);
}
}
function init() {
// 加载设置
setTheme(ls.get('theme', 'light'));
setSound(ls.get('sound', true));
setApiKey(ls.get('apiKey', ''));
updateSeedToggle();
// 移动端侧边栏的主题和声音按钮同步
qs('#soundToggleMobile').addEventListener('click', () => {
setSound(!state.sound);
});
qs('#themeToggleMobile').addEventListener('click', () => {
setTheme(state.theme === 'dark' ? 'light' : 'dark');
});
qs('#apiKeyInputMobile').addEventListener('input', (e) => {
setApiKey(e.target.value);
const desktopInput = qs('#apiKeyInput');
if (desktopInput) desktopInput.value = e.target.value;
});
// 桌面端事件绑定
qs('#themeToggle').addEventListener('click', () => {
setTheme(state.theme === 'dark' ? 'light' : 'dark');
});
qs('#soundToggle').addEventListener('click', () => {
setSound(!state.sound);
});
qs('#apiKeyInput').addEventListener('input', (e) => {
setApiKey(e.target.value);
const mobileInput = qs('#apiKeyInputMobile');
if (mobileInput) mobileInput.value = e.target.value;
});
qs('#seedToggle').addEventListener('click', () => {
state.seedRandom = !state.seedRandom;
updateSeedToggle();
});
qs('#chooseFolder').addEventListener('click', chooseFolder);
qs('#generateBtn').addEventListener('click', generate);
// 参考图选择事件
const imagesEl = qs('#images');
if (imagesEl) imagesEl.addEventListener('change', () => handleImagesChange(imagesEl, 'imagesStatus'));
const imagesElM = qs('#imagesMobile');
if (imagesElM) imagesElM.addEventListener('change', () => handleImagesChange(imagesElM, 'imagesStatusMobile'));
// 移动端事件绑定
qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
qs('#sidebarClose').addEventListener('click', closeSidebar);
qs('#overlay').addEventListener('click', closeSidebar);
qs('#seedToggleMobile').addEventListener('click', () => {
state.seedRandom = !state.seedRandom;
updateSeedToggle();
});
qs('#chooseFolderMobile').addEventListener('click', chooseFolder);
qs('#generateBtnMobile').addEventListener('click', () => {
// 先同步数据再生成
syncFormData('mobile', 'desktop');
generate();
closeSidebar();
});
// 模型切换时动态显示/隐藏特定参数
const ms = qs('#modelSelect');
if (ms) ms.addEventListener('change', () => updateVisibleFieldsFor(''));
const msm = qs('#modelSelectMobile');
if (msm) msm.addEventListener('change', () => updateVisibleFieldsFor('Mobile'));
// 初始化拖拽功能
initDragButton();
restoreButtonPosition();
// 设置实时同步
setupRealtimeSync();
// 通用事件绑定
qs('#downloadAll').addEventListener('click', () => {
qsa('#gallery img').forEach((img, i) => {
downloadImage(`image_${i+1}.jpg`, img.src);
});
});
// 键盘事件
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !state.sidebarOpen) generate();
if (e.key === 'Escape') {
if (isViewerOpen()) {
closeImageViewer();
} else if (state.sidebarOpen) {
closeSidebar();
}
}
});// 响应式检测
window.addEventListener('resize', checkMobile);
checkMobile();
// 页面可见性/生命周期:防止后台恢复后拖拽状态卡住,导致无法点击输入框
window.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// 页面恢复可见时,强制清除拖拽状态
if (typeof dragState !== 'undefined') {
dragState.isDragging = false;
const btn = qs('#mobileGenerateBtn');
if (btn) btn.classList.remove('dragging');
}
}
});
window.addEventListener('pagehide', () => {
if (typeof dragState !== 'undefined') {
dragState.isDragging = false;
const btn = qs('#mobileGenerateBtn');
if (btn) btn.classList.remove('dragging');
}
});
window.addEventListener('blur', () => {
if (typeof dragState !== 'undefined') {
dragState.isDragging = false;
const btn = qs('#mobileGenerateBtn');
if (btn) btn.classList.remove('dragging');
}
});
// 强制在页面获得焦点时清除拖拽状态
window.addEventListener('focus', () => {
if (typeof dragState !== 'undefined') {
dragState.isDragging = false;
const btn = qs('#mobileGenerateBtn');
if (btn) btn.classList.remove('dragging');
}
});
document.addEventListener('touchcancel', endDrag, { passive: false });
// 加载模型
fetchModels();
}
// 大图预览功能
function showImageModal(imageUrl, params) {
const modal = qs('#imageModal');
const modalImg = qs('#imageModalImg');
const modalInfo = qs('#imageModalInfo');
modalImg.src = imageUrl;
if (params) {
modalInfo.textContent = `${getDisplaySize(params)} | ${params.model}`;
} else {
modalInfo.textContent = '';
}
modal.classList.add('show');
// 阻止背景滚动
document.body.style.overflow = 'hidden';
}
function closeImageModal() {
const modal = qs('#imageModal');
modal.classList.remove('show');
document.body.style.overflow = '';
}
// 设置模态框事件监听
function setupImageModal() {
const modal = qs('#imageModal');
const closeBtn = qs('#imageModalClose');
const modalImg = qs('#imageModalImg');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeImageModal();
});
}
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeImageModal();
}
});
}
if (modalImg) {
modalImg.addEventListener('click', closeImageModal);
}
// ESC键关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) {
closeImageModal();
}
});
}
init();
setupImageModal();
</script>
</body>
</html>