admin-dressgenlist.html 9.31 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; }
  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; vertical-align: middle; }
  table.admin-table th { color: var(--muted); font-weight: 600; white-space: nowrap; }
  /* Operations column stays reachable without hunting for it when the table
     scrolls horizontally (many thumbnail columns on a narrow window) —
     that's what made "详情" hard to find before this page existed. */
  table.admin-table th:last-child, table.admin-table td:last-child { position: sticky; right: 0; background: #fff; box-shadow: -4px 0 6px -4px rgba(0,0,0,.15); white-space: nowrap; }
  .task-id { font-family: monospace; font-size: 11px; color: var(--muted); }
  .hint { color: var(--muted); font-size: 12px; }
  .thumb-group { display: flex; flex-wrap: wrap; gap: 4px; max-width: 160px; }
  .thumb-group figure { margin: 0; text-align: center; }
  .thumb-group img { width: 40px; height: 56px; object-fit: cover; border-radius: 3px; border: 1px solid var(--border); display: block; }
  .thumb-group figcaption { font-size: 9px; color: var(--muted); }
  .garment-thumb { width: 56px; height: 78px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
  .output-thumb { width: 72px; height: 100px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
  .output-missing { width: 72px; height: 100px; border: 1px dashed var(--border); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--muted); text-align: center; }
  img.clickable { cursor: zoom-in; }
  .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-partial { background: #fff8e1; color: #b26a00; }
  .status-failed { background: #fdecea; color: #c62828; }
  .row-actions { display: flex; flex-direction: column; gap: 4px; }
  .row-actions button { font-size: 12px; padding: 3px 8px; }
  #lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.82); display: flex; align-items: center; justify-content: center; cursor: zoom-out; z-index: 1000; }
  #lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 6px; }
</style>
</head>
<body>
<header class="topbar">
  <h1>人衣图任务列表</h1>
  <div class="spacer"></div>
  <a href="admin-dressgen.html">+ 新建任务</a>
  <a href="admin.html">视频库</a>
  <a href="admin-characters.html">人物库</a>
  <a href="admin-garments.html">衣服库</a>
  <a href="admin-batch-gen.html">批量生成</a>
  <a href="admin-dance-refs.html">参考跳舞视频库</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap">
  <div class="card">
    <div class="field"><label>人衣图任务列表(点任务号或"详情"进详情页;点图片可放大预览;调试时把任务号发我)</label></div>
    <div class="hint" id="countHint"></div>
    <div style="overflow-x:auto;">
      <table class="admin-table">
        <thead>
          <tr>
            <th>任务号</th>
            <th>供应商</th>
            <th>人物参考图</th>
            <th>衣服图</th>
            <th>生成 · 正面</th>
            <th>生成 · 侧面</th>
            <th>生成 · 背面</th>
            <th>状态</th>
            <th>时间</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
    </div>
    <div style="margin-top:10px;display:flex;gap:8px;align-items:center;">
      <button id="prevPageBtn">上一页</button>
      <button id="nextPageBtn">下一页</button>
    </div>
  </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;
}

const PERSON_SLOTS = ["bodyFront", "bodySide", "bodyBack", "faceFront", "faceSide", "faceBack"];
const PERSON_CAPTION = { bodyFront: "全身正", bodySide: "全身侧", bodyBack: "全身背", faceFront: "脸正", faceSide: "脸侧", faceBack: "脸背" };
const STATUS_LABEL = { queued: "排队中", generating: "生成中", done: "完成", partial: "部分完成", failed: "失败" };
const PROVIDER_LABEL = { image2: "GPT Image 2", wavespeed: "Seedream v4" };
const PAGE_SIZE = 20;
let records = [];
let total = 0;
let page = 1;
let pollTimer = null;

function anyInProgress() {
  return records.some((r) => r.status === "queued" || r.status === "generating");
}

function startPolling() {
  if (pollTimer) return;
  pollTimer = setInterval(async () => {
    await load();
    if (!anyInProgress()) {
      clearInterval(pollTimer);
      pollTimer = null;
    }
  }, 3000);
}

async function load() {
  const res = await fetch(`/api/admin/dressgen?page=${page}&pageSize=${PAGE_SIZE}`);
  const data = await res.json();
  records = data.items; // already newest-first from the server
  total = data.total;
  render();
}

function goToPage(p) {
  const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
  page = Math.min(Math.max(1, p), maxPage);
  load();
}

function outputCell(r, view) {
  const vs = (r.viewStatus && r.viewStatus[view]) || (r.outputImages && r.outputImages[view] ? "done" : "failed");
  const url = r.outputImages && r.outputImages[view];
  const promptTitle = escapeHtml((r.prompts && r.prompts[view]) || "");
  if (vs === "done" && url) return `<img class="output-thumb clickable" src="${escapeHtml(url)}" title="${promptTitle}" />`;
  if (vs === "queued") return `<div class="output-missing">排队中</div>`;
  if (vs === "generating") return `<div class="output-missing">生成中…</div>`;
  const err = escapeHtml((r.errors && r.errors[view]) || "失败");
  return `<div class="output-missing" title="${err}">失败</div>`;
}

function render() {
  const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
  document.getElementById("countHint").textContent = `共 ${total} 条,第 ${page}/${maxPage} 页`;
  document.getElementById("prevPageBtn").disabled = page <= 1;
  document.getElementById("nextPageBtn").disabled = page >= maxPage;
  document.getElementById("tbody").innerHTML = records
    .map((r) => {
      const personThumbs = PERSON_SLOTS.filter((f) => r[f])
        .map((f) => `<figure><img class="clickable" src="${escapeHtml(r[f])}" /><figcaption>${PERSON_CAPTION[f]}</figcaption></figure>`)
        .join("");
      const canRetry = r.status === "partial" || r.status === "failed";
      const actions = `
        <div class="row-actions">
          <a href="admin-dressgendetail.html?id=${r.id}">详情</a>
          ${canRetry ? `<button data-retry="${r.id}">重试失败视角</button>` : ""}
          <button class="danger" data-del="${r.id}">删除</button>
        </div>`;
      return `
        <tr>
          <td><a class="task-id" href="admin-dressgendetail.html?id=${r.id}" title="${r.id}">${r.id.slice(0, 8)}</a></td>
          <td>${PROVIDER_LABEL[r.provider] || r.provider || "GPT Image 2"}</td>
          <td><div class="thumb-group">${personThumbs}</div></td>
          <td><img class="garment-thumb clickable" src="${escapeHtml(r.garmentImage)}" /></td>
          <td>${outputCell(r, "front")}</td>
          <td>${outputCell(r, "side")}</td>
          <td>${outputCell(r, "back")}</td>
          <td><span class="status-badge status-${r.status}">${STATUS_LABEL[r.status] || r.status}</span></td>
          <td>${new Date(r.createdAt).toLocaleString()}</td>
          <td>${actions}</td>
        </tr>`;
    })
    .join("");

  document.querySelectorAll("#tbody img.clickable").forEach((img) =>
    img.addEventListener("click", () => openLightbox(img.src))
  );
  document.querySelectorAll("[data-del]").forEach((b) =>
    b.addEventListener("click", async () => {
      if (!confirm("确认删除这条任务?")) return;
      const res = await fetch(`/api/admin/dressgen/${b.dataset.del}`, { method: "DELETE" });
      if (res.ok) await load();
    })
  );
  document.querySelectorAll("[data-retry]").forEach((b) =>
    b.addEventListener("click", async () => {
      b.disabled = true;
      const res = await fetch(`/api/admin/dressgen/${b.dataset.retry}/retry`, { method: "POST" });
      if (res.ok) {
        await load();
        startPolling();
      } else {
        b.disabled = false;
      }
    })
  );
}

function openLightbox(src) {
  const box = document.createElement("div");
  box.id = "lightbox";
  box.innerHTML = `<img src="${src}" />`;
  box.addEventListener("click", () => box.remove());
  document.body.appendChild(box);
}

document.getElementById("prevPageBtn").addEventListener("click", () => goToPage(page - 1));
document.getElementById("nextPageBtn").addEventListener("click", () => goToPage(page + 1));

load().then(() => {
  if (anyInProgress()) startPolling();
});
</script>
</body>
</html>