admin-dressgendetail.html 9.43 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: 1200px; }
  .task-header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
  .task-id-box { font-family: monospace; font-size: 15px; background: #f4f4f4; border-radius: 4px; padding: 4px 10px; }
  .status-badge { padding: 3px 10px; border-radius: 4px; font-size: 13px; }
  .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; }
  .hint { color: var(--muted); font-size: 12px; }
  .inputs-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
  .inputs-grid figure { margin: 0; text-align: center; }
  .inputs-grid img { width: 84px; height: 118px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); display: block; cursor: zoom-in; }
  .inputs-grid figcaption { font-size: 11px; color: var(--muted); margin-top: 2px; }
  .view-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 12px; }
  .view-card { border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
  .view-card h3 { margin: 0 0 8px; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
  .view-output { width: 100%; height: 320px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; cursor: zoom-in; }
  .view-output-missing { width: 100%; height: 320px; border: 1px dashed var(--border); border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; }
  .view-error { color: #c62828; font-size: 12px; margin-top: 8px; white-space: pre-wrap; word-break: break-word; background: #fdecea; border-radius: 4px; padding: 8px; }
  .view-card textarea { width: 100%; min-height: 110px; font-size: 12px; margin-top: 8px; }
  .view-card button { margin-top: 8px; width: 100%; }
  #notFound { padding: 24px; }
  #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-dressgenlist.html">← 返回人衣图列表</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap" id="app" hidden>
  <div class="card">
    <div class="task-header">
      <span class="task-id-box" id="taskId"></span>
      <span class="status-badge" id="statusBadge"></span>
      <span class="hint" id="providerLabel"></span>
      <span class="hint" id="createdAt"></span>
      <span class="spacer"></span>
      <button id="retryAllBtn">重试所有未完成视角</button>
      <button class="danger" id="deleteBtn">删除任务</button>
    </div>
    <div class="hint" id="pollHint" style="margin-top:6px;"></div>

    <div class="field" style="margin-top:14px;"><label>输入:人物参考图 + 衣服图</label></div>
    <div class="inputs-grid" id="inputsGrid"></div>
  </div>

  <div class="card">
    <div class="field"><label>生成结果(正 / 侧 / 背,每张可单独重新生成)</label></div>
    <div class="view-grid" id="viewGrid"></div>
  </div>
</main>

<div id="notFound" hidden>
  <p>任务不存在或已被删除。<a href="admin-dressgenlist.html">返回列表</a></p>
</div>

<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;
}

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);
}

const id = new URLSearchParams(location.search).get("id");
const PERSON_SLOTS = ["bodyFront", "bodySide", "bodyBack", "faceFront", "faceSide", "faceBack"];
const PERSON_CAPTION = { bodyFront: "全身正", bodySide: "全身侧", bodyBack: "全身背", faceFront: "脸正", faceSide: "脸侧", faceBack: "脸背" };
const VIEWS = ["front", "side", "back"];
const VIEW_LABEL = { front: "正面", side: "侧面", back: "背面" };
const STATUS_LABEL = { queued: "排队中", generating: "生成中", done: "完成", partial: "部分完成", failed: "失败" };
const PROVIDER_LABEL = { image2: "GPT Image 2", wavespeed: "Seedream v4" };
let record = null;
let pollTimer = null;

if (!id) {
  document.getElementById("notFound").hidden = false;
} else {
  load();
}

async function load() {
  const res = await fetch(`/api/admin/dressgen/${id}`);
  if (!res.ok) {
    document.getElementById("notFound").hidden = false;
    document.getElementById("app").hidden = true;
    return;
  }
  record = await res.json();
  document.getElementById("app").hidden = false;
  render();
  if (record.status === "queued" || record.status === "generating") startPolling();
}

function startPolling() {
  if (pollTimer) return;
  pollTimer = setInterval(async () => {
    await load();
    if (record.status !== "queued" && record.status !== "generating" && pollTimer) {
      clearInterval(pollTimer);
      pollTimer = null;
    }
  }, 3000);
}

function render() {
  document.getElementById("taskId").textContent = record.id;
  document.getElementById("statusBadge").className = `status-badge status-${record.status}`;
  document.getElementById("statusBadge").textContent = STATUS_LABEL[record.status] || record.status;
  document.getElementById("createdAt").textContent = new Date(record.createdAt).toLocaleString();
  document.getElementById("providerLabel").textContent = `默认供应商: ${PROVIDER_LABEL[record.provider] || record.provider || "GPT Image 2"}`;
  document.getElementById("pollHint").textContent =
    record.status === "queued" || record.status === "generating" ? "任务进行中,页面每 3 秒自动刷新一次…" : "";
  document.getElementById("retryAllBtn").disabled = !(record.status === "partial" || record.status === "failed");

  document.getElementById("inputsGrid").innerHTML =
    PERSON_SLOTS.filter((f) => record[f])
      .map((f) => `<figure><img src="${escapeHtml(record[f])}" /><figcaption>${PERSON_CAPTION[f]}</figcaption></figure>`)
      .join("") + `<figure><img src="${escapeHtml(record.garmentImage)}" /><figcaption>衣服</figcaption></figure>`;
  document.querySelectorAll("#inputsGrid img").forEach((img) => img.addEventListener("click", () => openLightbox(img.src)));

  document.getElementById("viewGrid").innerHTML = VIEWS.map((view) => {
    const vs = (record.viewStatus && record.viewStatus[view]) || (record.outputImages[view] ? "done" : "failed");
    const url = record.outputImages[view];
    const viewProvider = record.viewProvider && record.viewProvider[view];
    let body;
    if (vs === "done" && url) body = `<img class="view-output" src="${escapeHtml(url)}" />`;
    else if (vs === "queued") body = `<div class="view-output-missing">排队中</div>`;
    else if (vs === "generating") body = `<div class="view-output-missing">生成中…</div>`;
    else body = `<div class="view-output-missing">失败</div>`;
    const providerHint = viewProvider ? `<div class="hint">实际使用: ${PROVIDER_LABEL[viewProvider] || viewProvider}</div>` : "";
    const errorBox = record.errors[view] ? `<div class="view-error">${escapeHtml(record.errors[view])}</div>` : "";
    const busy = vs === "queued" || vs === "generating";
    return `
      <div class="view-card" data-view="${view}">
        <h3>${VIEW_LABEL[view]}<span class="status-badge status-${vs}" style="font-size:11px;">${STATUS_LABEL[vs] || vs}</span></h3>
        ${body}
        ${providerHint}
        ${errorBox}
        <textarea data-prompt="${view}">${escapeHtml(record.prompts[view])}</textarea>
        <button data-regen="${view}" ${busy ? "disabled" : ""}>重新生成这张(可先改上面的 prompt)</button>
      </div>`;
  }).join("");

  document.querySelectorAll(".view-output").forEach((img) => img.addEventListener("click", () => openLightbox(img.src)));
  document.querySelectorAll("[data-regen]").forEach((b) =>
    b.addEventListener("click", async () => {
      const view = b.dataset.regen;
      const prompt = document.querySelector(`[data-prompt="${view}"]`).value;
      b.disabled = true;
      await fetch(`/api/admin/dressgen/${id}/retry/${view}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ prompt }),
      });
      await load();
    })
  );
}

document.getElementById("retryAllBtn").addEventListener("click", async () => {
  document.getElementById("retryAllBtn").disabled = true;
  await fetch(`/api/admin/dressgen/${id}/retry`, { method: "POST" });
  await load();
});

document.getElementById("deleteBtn").addEventListener("click", async () => {
  if (!confirm("确认删除这条任务?")) return;
  await fetch(`/api/admin/dressgen/${id}`, { method: "DELETE" });
  location.href = "admin-dressgenlist.html";
});
</script>
</body>
</html>