admin-batch-gen.html 9.69 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>冷启动批量生成</title>
<link rel="stylesheet" href="style.css" />
<style>
  .admin-wrap { padding: 18px 24px; max-width: 1400px; }
  .pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin-top: 8px; }
  .pick-card { border: 1px solid var(--border); border-radius: 6px; padding: 6px; text-align: center; cursor: pointer; font-size: 12px; }
  .pick-card.selected { border-color: #2e7d32; background: #eef7ee; }
  .pick-card img { width: 100%; height: 90px; object-fit: cover; border-radius: 4px; margin-bottom: 4px; }
  .section-label { font-weight: 600; margin-top: 16px; }
  .hint { color: var(--muted); font-size: 12px; }
  table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; margin-top: 12px; }
  table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; }
  .status-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
  .status-queued, .status-generating { background: #fff8e1; color: #b26a00; }
  .status-done { background: #eef7ee; color: #2e7d32; }
  .status-failed { background: #fdecea; color: #c62828; }
  .status-cached { background: #eceff1; color: #455a64; }
</style>
</head>
<body>
<header class="topbar">
  <h1>冷启动批量生成</h1>
  <div class="spacer"></div>
  <a href="admin.html">视频库</a>
  <a href="admin-characters.html">人物库</a>
  <a href="admin-garments.html">衣服库</a>
  <a href="admin-dance-refs.html">参考跳舞视频库</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap">
  <div class="card">
    <div class="section-label">1. 选一个人物</div>
    <div class="pick-grid" id="characterGrid"></div>

    <div class="section-label">2. 选衣服(可多选)</div>
    <div class="pick-grid" id="garmentGrid"></div>

    <div class="section-label">3. 参考跳舞视频(可选,不选则每条生成随机分配一条)</div>
    <div class="pick-grid" id="danceRefGrid"></div>

    <div class="hint" id="comboHint" style="margin-top:12px;"></div>
    <button id="submitBtn" style="margin-top:8px;">批量生成</button>
    <div id="submitError" class="error-box" hidden></div>
  </div>

  <div class="card" id="progressCard" hidden>
    <div class="section-label" style="margin-top:0;">进度</div>
    <div class="hint" id="progressHint"></div>
    <table class="admin-table">
      <thead>
        <tr>
          <th>衣服</th>
          <th>参考视频</th>
          <th>状态</th>
          <th>说明</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody id="progressBody"></tbody>
    </table>
  </div>
</main>

<script>
const _fetch = window.fetch;
window.fetch = async (...args) => {
  const res = await _fetch(...args);
  if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) {
    location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname);
  }
  return res;
};

function escapeHtml(s) {
  const div = document.createElement("div");
  div.textContent = s || "";
  return div.innerHTML;
}

let characters = [];
let garments = [];
let danceRefs = [];
let selectedCharacterId = null;
const selectedGarmentIds = new Set();
const selectedDanceRefIds = new Set();

// batchId -> { garmentId, refDanceVideoId, jobId?, cached?, clip?, name } rows
// pollTimer keeps refreshing job status until every row is done/failed.
let currentBatchId = null;
let progressRows = [];
let pollTimer = null;

async function loadAll() {
  [characters, garments, danceRefs] = await Promise.all([
    fetch("/api/admin/characters").then((r) => r.json()),
    fetch("/api/admin/garments").then((r) => r.json()),
    fetch("/api/admin/dance-refs").then((r) => r.json()),
  ]);
  garments = garments.filter((g) => g.isActive);
  danceRefs = danceRefs.filter((d) => d.isActive);
  renderPickers();
}

function renderPickers() {
  document.getElementById("characterGrid").innerHTML = characters
    .map(
      (c) => `
      <div class="pick-card ${c.id === selectedCharacterId ? "selected" : ""}" data-character="${c.id}">
        <img src="${escapeHtml(c.imageUrl)}" />
        ${escapeHtml(c.name)}
      </div>`
    )
    .join("");
  document.getElementById("garmentGrid").innerHTML = garments
    .map(
      (g) => `
      <div class="pick-card ${selectedGarmentIds.has(g.id) ? "selected" : ""}" data-garment="${g.id}">
        <img src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" />
        ${escapeHtml(g.name)}
      </div>`
    )
    .join("");
  document.getElementById("danceRefGrid").innerHTML = danceRefs
    .map(
      (d) => `
      <div class="pick-card ${selectedDanceRefIds.has(d.id) ? "selected" : ""}" data-danceref="${d.id}">
        ${escapeHtml(d.name)}
      </div>`
    )
    .join("");

  document.querySelectorAll("[data-character]").forEach((el) =>
    el.addEventListener("click", () => {
      selectedCharacterId = el.dataset.character;
      renderPickers();
      updateComboHint();
    })
  );
  document.querySelectorAll("[data-garment]").forEach((el) =>
    el.addEventListener("click", () => {
      const id = el.dataset.garment;
      selectedGarmentIds.has(id) ? selectedGarmentIds.delete(id) : selectedGarmentIds.add(id);
      renderPickers();
      updateComboHint();
    })
  );
  document.querySelectorAll("[data-danceref]").forEach((el) =>
    el.addEventListener("click", () => {
      const id = el.dataset.danceref;
      selectedDanceRefIds.has(id) ? selectedDanceRefIds.delete(id) : selectedDanceRefIds.add(id);
      renderPickers();
      updateComboHint();
    })
  );
  updateComboHint();
}

function updateComboHint() {
  const garmentCount = selectedGarmentIds.size;
  const refCount = selectedDanceRefIds.size || 1; // "不选" 展开成每条一次
  document.getElementById("comboHint").textContent = selectedCharacterId
    ? `将提交 ${garmentCount * refCount} 条生成任务(已有产物的组合会自动跳过)`
    : "先选一个人物";
}

async function submitBatch() {
  const errorBox = document.getElementById("submitError");
  errorBox.hidden = true;
  if (!selectedCharacterId || selectedGarmentIds.size === 0) {
    errorBox.hidden = false;
    errorBox.textContent = "至少选一个人物和一件衣服";
    return;
  }
  const body = {
    characterId: selectedCharacterId,
    garmentIds: [...selectedGarmentIds],
    refDanceVideoIds: [...selectedDanceRefIds],
  };
  const res = await fetch("/api/admin/batch-gen", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    errorBox.hidden = false;
    errorBox.textContent = (await res.json()).error || "提交失败";
    return;
  }
  const data = await res.json();
  currentBatchId = data.batchId;
  progressRows = data.results.map((r) => ({ ...r }));
  document.getElementById("progressCard").hidden = false;
  renderProgress();
  startPolling();
}

function nameFor(list, id) {
  const item = list.find((x) => x.id === id);
  return item ? item.name : id || "(随机分配)";
}

function renderProgress() {
  const done = progressRows.filter((r) => r.cached || r.status === "done" || r.status === "failed").length;
  document.getElementById("progressHint").textContent = `共 ${progressRows.length} 条,已完成 ${done} 条`;
  document.getElementById("progressBody").innerHTML = progressRows
    .map((r) => {
      const status = r.cached ? "cached" : r.status || "queued";
      const label = { cached: "已有产物(跳过)", queued: "排队中", generating: "生成中", done: "完成", failed: "失败" }[status] || status;
      const note = r.cached ? "" : r.error || "";
      const retryBtn = status === "failed" ? `<button class="secondary" data-retry='${JSON.stringify({ garmentId: r.garmentId, refDanceVideoId: r.refDanceVideoId })}'>重试</button>` : "";
      return `
        <tr>
          <td>${escapeHtml(nameFor(garments, r.garmentId))}</td>
          <td>${escapeHtml(nameFor(danceRefs, r.refDanceVideoId))}</td>
          <td><span class="status-badge status-${status}">${label}</span></td>
          <td>${escapeHtml(note)}</td>
          <td>${retryBtn}</td>
        </tr>`;
    })
    .join("");

  document.querySelectorAll("[data-retry]").forEach((b) =>
    b.addEventListener("click", async () => {
      const { garmentId, refDanceVideoId } = JSON.parse(b.dataset.retry);
      const res = await fetch("/api/admin/generate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ characterId: selectedCharacterId, garmentId, refDanceVideoId: refDanceVideoId || undefined }),
      });
      const data = await res.json();
      const row = progressRows.find((r) => r.garmentId === garmentId && (r.refDanceVideoId || null) === (refDanceVideoId || null));
      if (row) Object.assign(row, data, { status: data.cached ? "done" : "queued", error: null });
      renderProgress();
      startPolling();
    })
  );
}

function startPolling() {
  if (pollTimer) return;
  pollTimer = setInterval(async () => {
    if (!currentBatchId) return;
    const jobs = await fetch(`/api/admin/batch-gen/status?batchId=${currentBatchId}`).then((r) => r.json());
    for (const job of jobs) {
      const row = progressRows.find((r) => r.jobId === job.id);
      if (row) Object.assign(row, { status: job.status, error: job.error });
    }
    renderProgress();
    const allSettled = progressRows.every((r) => r.cached || r.status === "done" || r.status === "failed");
    if (allSettled) {
      clearInterval(pollTimer);
      pollTimer = null;
    }
  }, 3000);
}

document.getElementById("submitBtn").addEventListener("click", submitBatch);

loadAll();
</script>
</body>
</html>