使用 any 愉快的抽卡(并行画图)

any 用不了 opus?参考 [纯前端html] any的claude一直429用不了?那就拿来生图吧,理论上支持/v1/responses的站点都可以用 对这个 html 进行了增强,无需输入 url,无需输入模型,支持并行生成(抽卡当然要并行了),经过测试一个 key 只能同时生成一张,所以并...
使用 any 愉快的抽卡(并行画图)
使用 any 愉快的抽卡(并行画图)

any 用不了 opus?参考 [纯前端html] any的claude一直429用不了?那就拿来生图吧,理论上支持/v1/responses的站点都可以用
对这个 html 进行了增强,无需输入 url,无需输入模型,支持并行生成(抽卡当然要并行了),经过测试一个 key 只能同时生成一张,所以并行数取决于 key 数量
图片不应该超过1M,不然会失败

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AI 图片生成工具</title>
  <style>
    :root { color-scheme: dark; }
    body {
      margin: 0;
      font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
      background: #0b1020;
      color: #e5e7eb;
    }
    .wrap { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
    .card {
      display: grid; grid-template-columns: 1fr 380px; gap: 16px;
      background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 20px;
    }
    @media (max-width: 860px) { .card { grid-template-columns: 1fr; } }

    /* ====== 顶部 Tab ====== */
    .top-tabs { display: flex; gap: 0; margin-bottom: 16px; border-radius: 10px; overflow: hidden; border: 1px solid #374151; }
    .top-tab {
      flex: 1; text-align: center; padding: 12px; cursor: pointer; font-size: 15px; font-weight: 600;
      background: #0f172a; color: #94a3b8; transition: .15s; user-select: none;
    }
    .top-tab.active { background: #2563eb; color: #fff; }
    .top-tab:first-child { border-right: 1px solid #374151; }

    .tab-page { display: none; }
    .tab-page.active { display: block; }

    h1 { margin: 0 0 4px; font-size: 22px; grid-column: 1 / -1; }
    .sub { color: #94a3b8; font-size: 13px; margin-bottom: 0; grid-column: 1 / -1; }

    label { font-size: 13px; color: #cbd5e1; margin-bottom: 4px; display: block; }
    input, textarea {
      width: 100%; box-sizing: border-box;
      border: 1px solid #374151; background: #0f172a; color: #e2e8f0;
      border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit;
    }
    textarea { min-height: 80px; resize: vertical; }
    button {
      border: 0; background: #2563eb; color: #fff;
      padding: 10px 18px; border-radius: 8px; font-size: 14px; cursor: pointer;
    }
    button:disabled { opacity: .5; cursor: not-allowed; }
    button.secondary { background: #374151; }

    /* ====== 左栏 ====== */
    .main-panel { min-width: 0; }

    .setting-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
    .setting-row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 12px; }
    @media (max-width: 700px) { .setting-row3 { grid-template-columns: 1fr 1fr; } }
    @media (max-width: 500px) { .setting-row, .setting-row3 { grid-template-columns: 1fr; } }

    /* model 下拉 */
    .combo { position: relative; }
    .combo-panel {
      position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 50;
      background: #0f172a; border: 1px solid #334155; border-radius: 8px;
      max-height: 220px; overflow-y: auto; display: none;
      box-shadow: 0 8px 24px rgba(0,0,0,.5);
    }
    .combo-panel.open { display: block; }
    .combo-item {
      padding: 8px 12px; font-size: 12px; cursor: pointer; color: #e2e8f0;
      font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
    }
    .combo-item:hover, .combo-item.active { background: #1e293b; }
    .combo-empty { padding: 10px 12px; font-size: 11px; color: #64748b; }

    .pwd-wrap { position: relative; }
    .pwd-wrap input, .pwd-wrap textarea { padding-right: 40px; }
    .key-input { min-height: 42px; height: 42px; resize: vertical; line-height: 1.4; }
    .key-input.masked { -webkit-text-security: disc; }
    .pwd-toggle {
      position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
      background: none; border: 0; color: #6b7280; cursor: pointer;
      font-size: 16px; padding: 4px; line-height: 1;
    }
    .pwd-toggle:hover { color: #e5e7eb; }

    /* ====== 多图选择器 ====== */
    .img-picker { margin-bottom: 12px; }
    .thumb-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
    .thumb-item {
      width: 80px; height: 80px; border-radius: 8px; overflow: hidden;
      border: 1px solid #374151; cursor: pointer; position: relative;
      flex-shrink: 0; background: #0a0f18;
    }
    .thumb-item img { width: 100%; height: 100%; object-fit: cover; }
    .thumb-item .thumb-remove {
      position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
      border-radius: 50%; background: rgba(0,0,0,.75); color: #e5e7eb;
      border: 0; cursor: pointer; font-size: 12px; line-height: 18px;
      text-align: center; padding: 0; display: none;
    }
    .thumb-item:hover .thumb-remove { display: block; }
    .thumb-item .thumb-remove:hover { background: rgba(185,28,28,.9); }
    .thumb-add {
      width: 80px; height: 80px; border-radius: 8px;
      border: 2px dashed #374151; cursor: pointer; flex-shrink: 0;
      display: flex; align-items: center; justify-content: center;
      color: #64748b; font-size: 28px; background: #0f172a; transition: .15s;
    }
    .thumb-add:hover { border-color: #2563eb; color: #3b82f6; background: #111d32; }

    /* ====== 结果区域 ====== */
    .result-area {
      display: none; margin-top: 14px; text-align: center;
      animation: fadeInUp .4s ease;
    }
    .result-area.active { display: block; }
    @keyframes fadeInUp {
      from { opacity: 0; transform: translateY(16px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    .result-grid {
      display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;
    }
    .result-card {
      background: #0f172a; border: 1px solid #1f2937; border-radius: 10px;
      padding: 10px; animation: fadeInUp .4s ease;
    }
    .result-card img {
      width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 8px;
      border: 1px solid #374151; cursor: zoom-in; background: #0a0f18;
      transition: transform .15s;
    }
    .result-card img:hover { transform: scale(1.02); }
    .result-info {
      margin-top: 6px; font-size: 12px; color: #6b7280;
      display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;
    }
    .actions { margin-top: 10px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }

    .status-bar {
      margin-top: 12px; padding: 8px 12px; border-radius: 8px; font-size: 13px;
      background: #0f172a; border: 1px solid #1f2937;
      display: none; white-space: pre-wrap; word-break: break-word;
    }
    .status-bar.info { display: block; color: #93c5fd; border-color: #1e3a5f; background: #0c1929; }
    .status-bar.done { display: block; color: #bbf7d0; border-color: #166534; background: #052016; }
    .status-bar.err  { display: block; color: #fecdd3; border-color: #9f1239; background: #20060d; }

    /* ====== 右栏:实时面板 ====== */
    .side-panel {
      display: flex; flex-direction: column; gap: 8px; min-height: 520px;
      border-left: 1px solid #1f2937; padding-left: 16px;
    }
    @media (max-width: 860px) {
      .side-panel { border-left: 0; padding-left: 0; border-top: 1px solid #1f2937; padding-top: 14px; min-height: 0; }
    }

    .side-panel-header {
      font-size: 12px; color: #64748b; text-transform: uppercase;
      letter-spacing: .08em; display: flex; align-items: center; gap: 8px;
    }
    .side-panel-header::after {
      content: ''; flex: 1; height: 1px; background: #1f2937;
    }

    /* loading mini */
    .loading-mini {
      display: none; align-items: center; gap: 10px; padding: 8px 12px;
      border-radius: 8px; background: #0c1929; border: 1px solid #1e3a5f;
    }
    .loading-mini.active { display: flex; }
    .loading-mini .spinner-sm {
      width: 22px; height: 22px; flex-shrink: 0;
      border: 2px solid #1e3a5f; border-top-color: #3b82f6;
      border-radius: 50%; animation: spin .8s linear infinite;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    .loading-mini .stats-row {
      font-size: 11px; color: #64748b; display: flex; gap: 14px; flex-wrap: wrap;
    }

    /* 事件日志 */
    .event-log {
      display: none; flex: 1;
      border: 1px solid #1a2236; border-radius: 8px; background: #0a0f18;
      overflow-y: auto; font-size: 12px; min-height: 200px;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    }
    .event-log.active { display: block; }
    .event-log .line {
      padding: 2px 10px; border-bottom: 1px solid #0c1320;
      display: flex; gap: 6px; align-items: flex-start;
    }
    .event-log .line:last-child { border-bottom: 0; }
    .event-log .ts   { color: #4b5563; flex-shrink: 0; min-width: 54px; font-size: 11px; }
    .event-log .tag  { flex-shrink: 0; min-width: 44px; font-weight: 600; font-size: 11px; }
    .event-log .tag.event-tag { color: #f59e0b; }
    .event-log .tag.data-tag  { color: #3b82f6; }
    .event-log .tag.text-tag  { color: #6b7280; }
    .event-log .tag.done-tag  { color: #22c55e; }
    .event-log .msg  { color: #9ca3af; word-break: break-all; flex: 1; }

    /* 文本流 */
    .text-stream {
      display: none; padding: 8px 10px; border-radius: 8px;
      font-size: 12px; background: #0a0f18; border: 1px solid #1a2236;
      color: #9ca3af; overflow-y: auto; white-space: pre-wrap;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      max-height: 180px;
    }
    .text-stream.active { display: block; }

    /* ====== 全屏预览遮罩 ====== */
    .preview-overlay {
      display: none; position: fixed; inset: 0; z-index: 9999;
      background: rgba(0,0,0,.92); cursor: zoom-out;
      align-items: center; justify-content: center;
    }
    .preview-overlay.open { display: flex; }
    .preview-overlay img {
      max-width: 90vw; max-height: 90vh; object-fit: contain;
      border-radius: 6px; user-select: none;
      transition: transform .1s ease; cursor: grab;
    }
    .preview-overlay img.dragging { cursor: grabbing; transition: none; }
    .preview-close {
      position: absolute; top: 16px; right: 24px;
      font-size: 32px; color: #9ca3af; cursor: pointer;
      line-height: 1; z-index: 1; user-select: none;
    }
    .preview-close:hover { color: #fff; }
    .preview-hint {
      position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
      font-size: 12px; color: #6b7280; pointer-events: none;
    }

    /* ====== 图片展馆 ====== */
    .gallery-card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 20px; }
    .gallery-card h2 { margin: 0 0 4px; font-size: 16px; color: #e5e7eb; }
    .gallery-grid {
      display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px;
      margin-top: 12px;
    }
    .gallery-item {
      background: #0f172a; border: 1px solid #1f2937; border-radius: 10px;
      overflow: hidden; display: flex; flex-direction: column;
      animation: fadeInUp .3s ease;
    }
    .gallery-item .thumb-wrap {
      position: relative; overflow: hidden;
      background: #0a0f18; cursor: pointer;
    }
    .gallery-item .thumb-wrap img {
      width: 100%; height: auto; display: block; transition: transform .2s;
      aspect-ratio: 1; object-fit: cover;
    }
    .gallery-item .thumb-wrap:hover img { transform: scale(1.05); }
    .gallery-item .thumb-wrap .mode-badge {
      position: absolute; top: 6px; right: 6px;
      font-size: 10px; padding: 2px 8px; border-radius: 999px;
      background: rgba(0,0,0,.7); color: #e5e7eb;
    }
    .gallery-item .ref-row {
      display: flex; gap: 4px; padding: 4px 6px 0; flex-wrap: wrap;
    }
    .gallery-item .ref-thumb {
      width: 48px; height: 48px; border-radius: 4px; overflow: hidden;
      border: 1px solid #374151; cursor: pointer; flex-shrink: 0;
      background: #0a0f18;
    }
    .gallery-item .ref-thumb img { width: 100%; height: 100%; object-fit: cover; }
    .gallery-item .ref-thumb:hover { border-color: #3b82f6; }
    .gallery-item .info {
      padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; flex: 1;
    }
    .gallery-item .info .prompt-text {
      font-size: 12px; color: #9ca3af; line-height: 1.5;
      display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
      overflow: hidden; word-break: break-word; flex: 1;
    }
    .gallery-item .info .prompt-row { display: flex; gap: 6px; align-items: flex-start; }
    .gallery-item .info .copy-prompt {
      background: none; border: 0; color: #4b5563; cursor: pointer;
      font-size: 13px; padding: 1px 4px; line-height: 1; flex-shrink: 0;
    }
    .gallery-item .info .copy-prompt:hover { color: #e5e7eb; }
    .gallery-item .info .meta {
      font-size: 11px; color: #4b5563; display: flex; justify-content: space-between; align-items: center;
    }
    .gallery-item .info .del-btn {
      background: none; border: 0; color: #6b7280; cursor: pointer;
      font-size: 16px; padding: 2px 6px; line-height: 1;
    }
    .gallery-item .info .del-btn:hover { color: #ef4444; }
    .gallery-empty { text-align: center; color: #4b5563; font-size: 13px; padding: 24px 0; }

  </style>
</head>
<body>
  <div class="wrap">
    <!-- ============ 顶部 Tab ============ -->
    <div class="top-tabs">
      <div class="top-tab active" data-page="draw">画图</div>
      <div class="top-tab" data-page="gallery">图片展馆 <span id="topGalleryBadge" style="font-size:12px;opacity:.7;"></span></div>
    </div>

    <!-- ============ 画图页 ============ -->
    <div id="tabDraw" class="tab-page active">
    <div class="card">
      <h1>AI 图片生成</h1>
      <div class="sub">纯前端版:浏览器直连上游 API,支持文生图 & 图生图</div>

      <!-- ============ 左栏:输入 + 结果 ============ -->
      <div class="main-panel">

        <div class="setting-row3">
          <div>
            <label for="baseUrl">Base URL</label>
            <input id="baseUrl" placeholder="https://anyrouter.top" autocomplete="off" />
          </div>
          <div>
            <label for="apiKey">API Keys(每行一个,自动轮询)</label>
            <div class="pwd-wrap">
              <textarea id="apiKey" class="key-input masked" placeholder="sk-...&#10;sk-..." autocomplete="off" spellcheck="false"></textarea>
              <button id="pwdToggle" class="pwd-toggle" title="显示/隐藏">&#x1f441;</button>
            </div>
          </div>
        </div>

        <!-- 参考图片选择器(多图) -->
        <div class="img-picker">
          <label>参考图片(可选,上传后自动切换为图生图模式,支持多张)</label>
          <div class="thumb-row" id="thumbRow">
            <input id="imageFile" type="file" accept="image/*" multiple style="display:none;" />
            <div id="thumbAdd" class="thumb-add" title="添加参考图片">+</div>
          </div>
        </div>

        <div>
          <label for="prompt">图片描述(提示词)</label>
          <textarea id="prompt" placeholder="例如:一只在雨中奔跑的柴犬,皮克斯动画风格"></textarea>
        </div>

        <div style="margin-top:12px;">
          <label for="batchCount">生成张数</label>
          <input id="batchCount" type="number" min="1" step="1" value="1" />
        </div>

        <button id="genBtn" style="margin-top:10px; width:100%;">生成图片</button>

        <div id="resultArea" class="result-area">
          <div class="result-info"><span id="resultStats"></span></div>
          <div class="actions">
            <button id="downloadAllBtn">下载本次全部</button>
          </div>
          <div id="resultGrid" class="result-grid"></div>
        </div>

        <div id="statusBar" class="status-bar"></div>
      </div>

      <!-- ============ 右栏:实时事件流 ============ -->
      <div class="side-panel">
        <div class="side-panel-header">实时事件流</div>

        <div id="loadingMini" class="loading-mini">
          <div class="spinner-sm"></div>
          <div class="stats-row">
            <span id="statEvents">📡 事件: 0</span>
            <span id="statTextLen">📝 文本: 0 字</span>
            <span id="statElapsed">⏱ 0s</span>
          </div>
        </div>

        <div id="eventLog" class="event-log"></div>
        <div id="textStream" class="text-stream"></div>
      </div>
      </div>
    </div>

    <!-- ============ 全屏预览遮罩(全局,跨 Tab) ============ -->
    <div id="previewOverlay" class="preview-overlay">
      <span class="preview-close">&times;</span>
      <img id="previewImg" alt="预览" draggable="false" />
      <span class="preview-hint">滚轮缩放  ·  ESC 关闭</span>
    </div>

    <!-- ============ 图片展馆页 ============ -->
    <div id="tabGallery" class="tab-page">
    <div class="gallery-card">
      <h2>生成记录展馆 <span id="galleryCount" style="font-size:13px;color:#64748b;font-weight:400;"></span></h2>
      <div id="galleryGrid" class="gallery-grid"></div>
      <div id="galleryEmpty" class="gallery-empty" style="display:none;">暂无生成记录,快去生成第一张图片吧</div>
    </div>
    </div>

    </div>

  <script>
    // ========== DOM 引用 ==========
    const $ = (s) => document.querySelector(s);
    const $$ = (s) => document.querySelectorAll(s);
    const els = {
      topTabs: $$('.top-tab'),
      tabDraw: $('#tabDraw'),
      tabGallery: $('#tabGallery'),
      topGalleryBadge: $('#topGalleryBadge'),
      apiKey: $('#apiKey'),
      pwdToggle: $('#pwdToggle'),
      baseUrl: $('#baseUrl'),
      imageFile: $('#imageFile'),
      thumbRow: $('#thumbRow'),
      thumbAdd: $('#thumbAdd'),
      prompt: $('#prompt'),
      batchCount: $('#batchCount'),
      genBtn: $('#genBtn'),
      loadingMini: $('#loadingMini'),
      statEvents: $('#statEvents'),
      statTextLen: $('#statTextLen'),
      statElapsed: $('#statElapsed'),
      eventLog: $('#eventLog'),
      textStream: $('#textStream'),
      statusBar: $('#statusBar'),
      resultArea: $('#resultArea'),
      resultGrid: $('#resultGrid'),
      resultStats: $('#resultStats'),
      downloadAllBtn: $('#downloadAllBtn'),
      previewOverlay: $('#previewOverlay'),
      previewImg: $('#previewImg'),
      galleryGrid: $('#galleryGrid'),
      galleryCount: $('#galleryCount'),
      galleryEmpty: $('#galleryEmpty'),
    };

    let resultDataUrl = null;
    let currentResults = [];

    // ========== 多图选择器 ==========
    let refImages = []; // { file, dataUrl }

    function renderThumbnails() {
      // 清除旧缩略图
      els.thumbRow.querySelectorAll('.thumb-item').forEach((el) => el.remove());
      refImages.forEach((ref, i) => {
        const item = document.createElement('div');
        item.className = 'thumb-item';
        const img = document.createElement('img');
        img.src = ref.dataUrl;
        img.alt = '参考图 ' + (i + 1);
        img.addEventListener('click', (e) => {
          e.stopPropagation();
          resultDataUrl = ref.dataUrl;
          openPreview();
        });
        item.appendChild(img);
        const rm = document.createElement('button');
        rm.className = 'thumb-remove';
        rm.textContent = '×';
        rm.title = '移除';
        rm.addEventListener('click', (e) => {
          e.stopPropagation();
          refImages.splice(i, 1);
          renderThumbnails();
        });
        item.appendChild(rm);
        els.thumbRow.insertBefore(item, els.thumbAdd);
      });
      // 保留 add 按钮事件
    }

    function addRefFiles(files) {
      const valid = Array.from(files).filter((f) => {
        if (f.size > 50 * 1024 * 1024) { alert(`${f.name} 超过 50MB,已跳过`); return false; }
        return true;
      });
      valid.forEach((file) => {
        const reader = new FileReader();
        reader.onload = () => {
          refImages.push({ file, dataUrl: reader.result });
          renderThumbnails();
        };
        reader.readAsDataURL(file);
      });
      els.imageFile.value = '';
    }

    function clearRefImages() {
      refImages = [];
      renderThumbnails();
    }

    function lockInputs() {
      els.apiKey.disabled = true;
      els.baseUrl.disabled = true;
      els.prompt.disabled = true;
      els.batchCount.disabled = true;
      els.thumbAdd.style.display = 'none';
      els.thumbRow.querySelectorAll('.thumb-remove').forEach((b) => { b.style.display = 'none'; });
    }

    function unlockInputs() {
      els.apiKey.disabled = false;
      els.baseUrl.disabled = false;
      els.prompt.disabled = false;
      els.batchCount.disabled = false;
      els.thumbAdd.style.display = '';
      els.thumbRow.querySelectorAll('.thumb-remove').forEach((b) => { b.style.display = ''; });
    }

    els.thumbAdd.addEventListener('click', () => els.imageFile.click());
    els.imageFile.addEventListener('change', () => {
      if (els.imageFile.files.length) addRefFiles(els.imageFile.files);
    });

    // ========== 顶部 Tab 切换 ==========
    els.topTabs.forEach((tab) => {
      tab.addEventListener('click', () => {
        els.topTabs.forEach((t) => t.classList.remove('active'));
        tab.classList.add('active');
        const page = tab.dataset.page;
        els.tabDraw.classList.toggle('active', page === 'draw');
        els.tabGallery.classList.toggle('active', page === 'gallery');
      });
    });

    // ========== localStorage ==========
    const SAVED_KEY = 'img_gen_settings';

    // 恢复上次成功生成时保存的设置
    try {
      const saved = localStorage.getItem(SAVED_KEY);
      if (saved) {
        const s = JSON.parse(saved);
        if (s.baseUrl) els.baseUrl.value = s.baseUrl;
        if (s.apiKey || s.model) {
          delete s.apiKey;
          delete s.model;
          localStorage.setItem(SAVED_KEY, JSON.stringify(s));
        }
        if (s.batchCount) els.batchCount.value = s.batchCount;
      }
    } catch {}

    function saveSettings() {
      try {
        localStorage.setItem(SAVED_KEY, JSON.stringify({
          baseUrl: els.baseUrl.value.trim(),
          batchCount: els.batchCount.value,
        }));
      } catch {}
    }

    const DEFAULT_BASE_URL = 'https://anyrouter.top';
    const MODEL_ID = 'gpt-5.3-codex';

    function normalizeBaseUrl(input) {
      let base = String(input || '').trim() || DEFAULT_BASE_URL;
      if (!/^https?:\/\//i.test(base)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
      base = base.replace(/\/+$/, '');
      // 统一由请求路径拼接 /v1。
      if (base.endsWith('/v1')) base = base.slice(0, -3);
      return base;
    }

    function getApiKeys() {
      return els.apiKey.value.split(/[\n,,]+/).map((key) => key.trim()).filter(Boolean);
    }

    function getBatchCount() {
      const count = Number.parseInt(els.batchCount.value, 10);
      if (!Number.isFinite(count) || count < 1) return 1;
      return count;
    }

    // ========== 图片展馆 (IndexedDB) ==========
    const DB_NAME = 'img-gen-gallery';
    const DB_VERSION = 1;
    let gallery = [];

    function openDB() {
      return new Promise((resolve, reject) => {
        const req = indexedDB.open(DB_NAME, DB_VERSION);
        req.onupgradeneeded = () => {
          if (!req.result.objectStoreNames.contains('records')) {
            req.result.createObjectStore('records', { keyPath: 'id' });
          }
        };
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
      });
    }

    async function loadGallery() {
      try {
        const db = await openDB();
        const tx = db.transaction('records', 'readonly');
        const all = await new Promise((resolve, reject) => {
          const req = tx.objectStore('records').getAll();
          req.onsuccess = () => resolve(req.result);
          req.onerror = () => reject(req.error);
        });
        gallery = all.sort((a, b) => b.id - a.id);
        db.close();
      } catch { /* gallery 保持空 */ }
    }

    async function saveRecord(record) {
      try {
        const db = await openDB();
        const tx = db.transaction('records', 'readwrite');
        tx.objectStore('records').put(record);
        await new Promise((resolve, reject) => {
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        });
        db.close();
      } catch {}
    }

    async function deleteRecord(id) {
      try {
        const db = await openDB();
        const tx = db.transaction('records', 'readwrite');
        tx.objectStore('records').delete(id);
        await new Promise((resolve, reject) => {
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        });
        db.close();
      } catch {}
    }

    async function addToGallery(dataUrl, prompt, mode, refDataUrl) {
      const id = Date.now() + Math.random();
      const record = {
        id,
        dataUrl,
        prompt,
        mode,
        refDataUrl: refDataUrl || null,
        time: new Date().toLocaleString('zh-CN'),
      };
      gallery.unshift(record);
      renderGallery();
      saveRecord(record);
    }

    async function deleteFromGallery(id) {
      gallery = gallery.filter((r) => r.id !== id);
      renderGallery();
      deleteRecord(id);
    }

    function renderGallery() {
      els.galleryGrid.innerHTML = '';
      if (gallery.length === 0) {
        els.galleryEmpty.style.display = '';
        els.galleryCount.textContent = '';
        els.topGalleryBadge.textContent = '';
        return;
      }
      els.galleryEmpty.style.display = 'none';
      els.galleryCount.textContent = `(${gallery.length} 张)`;
      els.topGalleryBadge.textContent = `(${gallery.length})`;

      for (const r of gallery) {
        const card = document.createElement('div');
        card.className = 'gallery-item';

        const thumbWrap = document.createElement('div');
        thumbWrap.className = 'thumb-wrap';
        const img = document.createElement('img');
        img.src = r.dataUrl;
        img.alt = '生成的图片';
        img.loading = 'lazy';
        img.addEventListener('click', () => {
          resultDataUrl = r.dataUrl;
          openPreview();
        });
        thumbWrap.appendChild(img);

        const badge = document.createElement('span');
        badge.className = 'mode-badge';
        badge.textContent = r.mode === 1 ? '文生图' : '图生图';
        thumbWrap.appendChild(badge);

        card.appendChild(thumbWrap);

        // 参考图行(图生图)
        if (r.refDataUrl) {
          const refRow = document.createElement('div');
          refRow.className = 'ref-row';
          const refThumb = document.createElement('div');
          refThumb.className = 'ref-thumb';
          const refImg = document.createElement('img');
          refImg.src = r.refDataUrl;
          refImg.alt = '参考图';
          refThumb.addEventListener('click', (e) => {
            e.stopPropagation();
            resultDataUrl = r.refDataUrl;
            openPreview();
          });
          refThumb.appendChild(refImg);
          refRow.appendChild(refThumb);
          card.appendChild(refRow);
        }

        // 信息区域
        const info = document.createElement('div');
        info.className = 'info';

        const promptRow = document.createElement('div');
        promptRow.className = 'prompt-row';
        const promptEl = document.createElement('div');
        promptEl.className = 'prompt-text';
        promptEl.textContent = r.prompt;
        promptEl.title = r.prompt;
        promptRow.appendChild(promptEl);

        const copyBtn = document.createElement('button');
        copyBtn.className = 'copy-prompt';
        copyBtn.innerHTML = '&#x1f4cb;';
        copyBtn.title = '复制提示词';
        copyBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          navigator.clipboard.writeText(r.prompt).then(() => {
            copyBtn.innerHTML = '&#x2705;';
            setTimeout(() => { copyBtn.innerHTML = '&#x1f4cb;'; }, 1200);
          });
        });
        promptRow.appendChild(copyBtn);
        info.appendChild(promptRow);

        const meta = document.createElement('div');
        meta.className = 'meta';
        const timeSpan = document.createElement('span');
        timeSpan.textContent = r.time;
        meta.appendChild(timeSpan);
        const delBtn = document.createElement('button');
        delBtn.className = 'del-btn';
        delBtn.innerHTML = '&#x1f5d1;';
        delBtn.title = '删除';
        delBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          deleteFromGallery(r.id);
        });
        meta.appendChild(delBtn);
        info.appendChild(meta);

        card.appendChild(info);
        els.galleryGrid.appendChild(card);
      }
    }

    // 页面加载时从 IndexedDB 恢复
    loadGallery().then(() => renderGallery());

    // ========== 密码显隐切换 ==========
    els.pwdToggle.addEventListener('click', () => {
      const masked = els.apiKey.classList.toggle('masked');
      els.pwdToggle.textContent = masked ? '\u{1f441}' : '\u{1f441}\u{200d}\u{1f5e8}';
    });

    // ========== 事件日志 ==========
    let eventCount = 0;
    function appendEvent(type, msg) {
      eventCount++;
      els.statEvents.textContent = `📡 事件: ${eventCount}`;
      els.eventLog.classList.add('active');
      const now = new Date();
      const ts = now.toTimeString().slice(0, 8);
      const tagClass = type === 'event' ? 'event-tag' : type === 'data' ? 'data-tag' : type === 'text' ? 'text-tag' : 'done-tag';
      const tagLabel = type === 'event' ? 'EVENT' : type === 'data' ? 'DATA' : type === 'text' ? 'TEXT' : 'DONE';

      const line = document.createElement('div');
      line.className = 'line';
      line.innerHTML = `<span class="ts">${ts}</span><span class="tag ${tagClass}">${tagLabel}</span><span class="msg">${escapeHtml(msg)}</span>`;
      els.eventLog.appendChild(line);
      els.eventLog.scrollTop = els.eventLog.scrollHeight;
    }
    function escapeHtml(s) {
      return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
    }

    // ========== 计时器 ==========
    let startTime = 0;
    let timerInterval = null;
    function startTimer() {
      startTime = Date.now();
      els.statElapsed.textContent = '⏱ 0s';
      timerInterval = setInterval(() => {
        const s = Math.floor((Date.now() - startTime) / 1000);
        els.statElapsed.textContent = `⏱ ${s}s`;
      }, 500);
    }
    function stopTimer() {
      clearInterval(timerInterval);
      timerInterval = null;
      const s = ((Date.now() - startTime) / 1000).toFixed(1);
      els.statElapsed.textContent = `⏱ ${s}s`;
    }

    // ========== 递归搜索 base64 ==========
    function extractBase64AsDataUrl(dataObj) {
      if (dataObj == null) return null;
      if (Array.isArray(dataObj)) {
        for (const item of dataObj) {
          const found = extractBase64AsDataUrl(item);
          if (found) return found;
        }
      } else if (typeof dataObj === 'object') {
        for (const [key, value] of Object.entries(dataObj)) {
          if (key === 'result' && typeof value === 'string' && (value.startsWith('iVBOR') || value.length > 1000)) {
            return 'data:image/png;base64,' + value;
          }
          const found = extractBase64AsDataUrl(value);
          if (found) return found;
        }
      }
      return null;
    }

    // ========== 构建请求体 ==========
    async function buildPayload() {
      const prompt = els.prompt.value.trim();
      const model = MODEL_ID;

      if (refImages.length > 0) {
        const editPrompt = `请根据以下要求,对我提供的参考图片进行编辑修改,直接生成修改后的新图片。要求:${prompt}`;
        const content = refImages.map((ref) => ({ type: 'input_image', image_url: ref.dataUrl }));
        content.push({ type: 'input_text', text: editPrompt });
        return {
          model,
          input: [{ role: 'user', content }],
          tools: [{ type: 'image_generation', output_format: 'png' }],
          stream: true,
        };
      } else {
        return {
          model,
          input: [
            { role: 'system', content: '你是一个图片生成助手。用户要求你生成图片时,你必须调用 image_generation 工具来生成图片,不要用文字描述图片内容。直接生成图片,不要多说任何话。' },
            { role: 'user', content: `请生成以下描述的图片:${prompt}` },
          ],
          tools: [{ type: 'image_generation', output_format: 'png' }],
          stream: true,
        };
      }
    }

    // ========== UI 重置 ==========
    function resetUI() {
      els.loadingMini.classList.remove('active');
      els.eventLog.classList.remove('active');
      els.eventLog.innerHTML = '';
      els.textStream.classList.remove('active');
      els.textStream.textContent = '';
      els.statusBar.className = 'status-bar';
      els.statusBar.style.display = '';
      els.statusBar.textContent = '';
      els.resultArea.classList.remove('active');
      els.resultGrid.innerHTML = '';
      els.resultStats.textContent = '';
      els.genBtn.disabled = false;
      els.genBtn.textContent = '生成图片';
      resultDataUrl = null;
      currentResults = [];
      eventCount = 0;
      stopTimer();
    }

    function dataUrlToBlob(dataUrl) {
      const b64 = dataUrl.split(',')[1];
      const byteChars = atob(b64);
      const bytes = new Uint8Array(byteChars.length);
      for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i);
      return new Blob([bytes], { type: 'image/png' });
    }

    function formatSize(bytes) {
      const sizeKB = (bytes / 1024).toFixed(1);
      return sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) + ' MB' : sizeKB + ' KB';
    }

    function addCurrentResult(dataUrl, blob, prompt, index, total, sizeStr) {
      const result = { dataUrl, blob, prompt, index, sizeStr };
      currentResults.push(result);
      resultDataUrl = dataUrl;
      els.resultArea.classList.add('active');
      els.resultStats.textContent = `本次已生成 ${currentResults.length}/${total} 张`;

      const card = document.createElement('div');
      card.className = 'result-card';

      const img = document.createElement('img');
      img.src = dataUrl;
      img.alt = `生成的图片 ${index}`;
      img.addEventListener('click', () => {
        resultDataUrl = dataUrl;
        openPreview();
      });
      card.appendChild(img);

      const info = document.createElement('div');
      info.className = 'result-info';
      info.textContent = `第 ${index} 张 · ${sizeStr}`;
      card.appendChild(info);

      const actions = document.createElement('div');
      actions.className = 'actions';

      const downloadBtn = document.createElement('button');
      downloadBtn.textContent = '下载';
      downloadBtn.addEventListener('click', () => downloadBlob(blob, downloadFilename(index)));
      actions.appendChild(downloadBtn);

      const copyBtn = document.createElement('button');
      copyBtn.className = 'secondary';
      copyBtn.textContent = '复制';
      copyBtn.addEventListener('click', () => copyBlob(blob, copyBtn));
      actions.appendChild(copyBtn);

      card.appendChild(actions);
      els.resultGrid.appendChild(card);
    }

    async function requestOneImage({ baseUrl, apiKey, payload, prompt, index, total }) {
      const url = baseUrl + '/v1/responses';
      const headers = {
        'Authorization': 'Bearer ' + apiKey,
        'chatgpt-account-id': '',
        'version': '0.122.0',
        'originator': 'codex_cli_rs',
        'session_id': 'browser-' + Date.now() + '-' + index,
        'accept': 'text/event-stream',
        'Content-Type': 'application/json',
      };

      let collectedText = [];
      appendEvent('event', `开始第 ${index}/${total} 张:POST /v1/responses`);

      const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
      if (!response.ok) {
        let errText = '';
        try { errText = await response.text(); } catch {}
        throw new Error(`第 ${index} 张失败:HTTP ${response.status}${errText ? ' — ' + errText.slice(0, 300) : ''}`);
      }

      appendEvent('event', `第 ${index} 张 HTTP ${response.status} — 连接成功`);

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop();

        for (const line of lines) {
          const trimmed = line.trim();
          if (!trimmed) continue;

          if (trimmed.startsWith('event: ')) {
            const eventName = trimmed.slice(7);
            if (eventName !== 'response.output_text.delta') appendEvent('event', eventName);
            continue;
          }

          if (!trimmed.startsWith('data: ')) continue;
          const dataStr = trimmed.slice(6);
          if (dataStr === '[DONE]' || dataStr === '') continue;

          try {
            const data = JSON.parse(dataStr);
            const dataUrl = extractBase64AsDataUrl(data);
            if (dataUrl) {
              const blob = dataUrlToBlob(dataUrl);
              const sizeStr = formatSize(blob.size);
              addCurrentResult(dataUrl, blob, prompt, index, total, sizeStr);
              appendEvent('done', `第 ${index} 张图片已抓取,大小: ${sizeStr}`);
              const mode = refImages.length > 0 ? 2 : 1;
              const refUrl = refImages.length > 0 ? refImages[0].dataUrl : null;
              addToGallery(dataUrl, prompt, mode, refUrl);
              return true;
            }

            const delta = data.delta;
            if (typeof delta === 'string' && delta) {
              collectedText.push(delta);
              els.textStream.classList.add('active');
              els.textStream.textContent += delta;
              els.textStream.scrollTop = els.textStream.scrollHeight;
              els.statTextLen.textContent = `📝 文本: ${collectedText.join('').length} 字`;
              if (collectedText.length % 5 === 1) appendEvent('text', `第 ${index} 张已接收 ${collectedText.length} 个 delta`);
            }

            if (!data.delta && eventCount % 3 === 0) {
              const keys = Object.keys(data).slice(0, 3).join(', ');
              appendEvent('data', keys ? `{ ${keys}... }` : `[${typeof data}]`);
            }
          } catch {}
        }
      }

      if (collectedText.length) {
        appendEvent('done', `第 ${index} 张只返回文本,未生成图片`);
      } else {
        appendEvent('done', `第 ${index} 张流结束,未收到图片`);
      }
      return false;
    }

    // ========== 核心:发送请求 ==========
    async function generate() {
      const apiKeys = getApiKeys();
      if (!apiKeys.length) { alert('请填写至少一个 API Key'); return; }

      let baseUrl;
      try { baseUrl = normalizeBaseUrl(els.baseUrl.value); }
      catch (e) { alert(e.message); return; }

      const prompt = els.prompt.value.trim();
      if (!prompt) { alert('请填写提示词'); return; }

      const total = getBatchCount();
      els.batchCount.value = total;
      saveSettings();

      let payload;
      try { payload = await buildPayload(); }
      catch (e) { alert(e.message); return; }

      resetUI();
      lockInputs();
      els.genBtn.disabled = true;
      els.genBtn.textContent = total > 1 ? `生成中 0/${total}…` : '生成中…';
      els.loadingMini.classList.add('active');
      els.statEvents.textContent = '📡 事件: 0';
      els.statTextLen.textContent = '📝 文本: 0 字';
      startTimer();

      let successCount = 0;
      let finishedCount = 0;
      let nextIndex = 1;
      try {
        const workers = apiKeys.map(async (apiKey, keyIndex) => {
          while (nextIndex <= total) {
            const index = nextIndex++;
            try {
              appendEvent('event', `第 ${index}/${total} 张分配给 key ${keyIndex + 1}`);
              const ok = await requestOneImage({ baseUrl, apiKey, payload, prompt, index, total });
              if (ok) successCount++;
            } catch (err) {
              appendEvent('event', `第 ${index} 张(key ${keyIndex + 1})失败:${err.message || err}`);
            } finally {
              finishedCount++;
              els.genBtn.textContent = total > 1 ? `生成中 ${finishedCount}/${total}…` : '生成中…';
            }
          }
        });
        await Promise.all(workers);
      } finally {
        stopTimer();
        els.loadingMini.classList.remove('active');
        unlockInputs();
        els.genBtn.disabled = false;
        els.genBtn.textContent = '重新生成';
        if (successCount > 0) clearRefImages();
      }

      if (successCount > 0) {
        els.statusBar.className = successCount === total ? 'status-bar done' : 'status-bar info';
        els.statusBar.textContent = `完成:成功生成 ${successCount}/${total} 张,已加入图片展馆。`;
        els.resultStats.textContent = `本次生成 ${successCount}/${total} 张 · 📡 ${eventCount} 个事件 · ⏱ ${((Date.now() - startTime) / 1000).toFixed(1)}s`;
      } else {
        els.statusBar.className = 'status-bar err';
        els.statusBar.textContent = '没有生成出图片,请查看右侧事件流里的错误信息。';
      }
    }

    // ========== 下载 ==========
    function downloadFilename(index = 1) {
      const d = new Date();
      const pad = (n) => String(n).padStart(2, '0');
      return `img-${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}-${String(index).padStart(2, '0')}.png`;
    }

    function downloadBlob(blob, filename) {
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
    }

    function downloadAll() {
      currentResults.forEach((result) => downloadBlob(result.blob, downloadFilename(result.index)));
    }

    // ========== 复制 ==========
    async function copyBlob(blob, button) {
      try {
        await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
        const orig = button.textContent;
        button.textContent = '已复制!';
        setTimeout(() => { button.textContent = orig; }, 1500);
      } catch {
        alert('复制失败,浏览器可能不支持。请用下载替代。');
      }
    }

    // ========== 图片预览(点击放大 / ESC 关闭 / 滚轮缩放 / 拖拽平移) ==========
    let previewScale = 1;
    let panX = 0, panY = 0;
    let isDragging = false;
    let dragStartX = 0, dragStartY = 0;
    let panStartX = 0, panStartY = 0;

    function updatePreviewTransform() {
      els.previewImg.style.transform = `translate(${panX}px, ${panY}px) scale(${previewScale})`;
    }

    function openPreview() {
      if (!resultDataUrl) return;
      previewScale = 1;
      panX = 0;
      panY = 0;
      els.previewImg.src = resultDataUrl;
      updatePreviewTransform();
      els.previewOverlay.classList.add('open');
      document.body.style.overflow = 'hidden';
    }

    function closePreview() {
      els.previewOverlay.classList.remove('open');
      document.body.style.overflow = '';
    }

    els.previewOverlay.addEventListener('click', (e) => {
      if (e.target === els.previewOverlay || e.target.classList.contains('preview-close')) {
        closePreview();
      }
    });
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && els.previewOverlay.classList.contains('open')) {
        closePreview();
      }
    });

    // 滚轮缩放(以鼠标位置为锚点)
    els.previewOverlay.addEventListener('wheel', (e) => {
      if (!els.previewOverlay.classList.contains('open')) return;
      e.preventDefault();

      const rect = els.previewImg.getBoundingClientRect();
      const fx = (e.clientX - rect.left) / rect.width;
      const fy = (e.clientY - rect.top) / rect.height;

      const oldScale = previewScale;
      previewScale = Math.max(0.2, Math.min(5, previewScale + (e.deltaY > 0 ? -0.1 : 0.1)));

      const ratio = previewScale / oldScale;
      const newLeft = e.clientX - fx * rect.width * ratio;
      const newTop  = e.clientY - fy * rect.height * ratio;

      panX += newLeft - rect.left;
      panY += newTop  - rect.top;
      updatePreviewTransform();
    });

    // 拖拽平移
    els.previewImg.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      isDragging = true;
      els.previewImg.classList.add('dragging');
      dragStartX = e.clientX;
      dragStartY = e.clientY;
      panStartX = panX;
      panStartY = panY;
      e.preventDefault();
    });
    window.addEventListener('mousemove', (e) => {
      if (!isDragging) return;
      panX = panStartX + (e.clientX - dragStartX);
      panY = panStartY + (e.clientY - dragStartY);
      updatePreviewTransform();
    });
    window.addEventListener('mouseup', () => {
      if (!isDragging) return;
      isDragging = false;
      els.previewImg.classList.remove('dragging');
    });

    // ========== 事件绑定 ==========
    els.genBtn.addEventListener('click', generate);
    els.downloadAllBtn.addEventListener('click', downloadAll);
    els.prompt.addEventListener('keydown', (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') generate();
    });
  </script>
</body>
</html>

4 个帖子 - 3 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文