admin-dance-qc.html 10.2 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: 1100px; }
  .stat-row { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
  .stat-card { background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; min-width: 100px; }
  .stat-card .num { font-size: 22px; font-weight: 700; }
  .stat-card .label { font-size: 12px; color: var(--muted); }
  .stat-card.incorrect .num { color: #c0392b; }
  .stat-card.correct .num { color: #2e7d32; }
  .stat-card.pending .num { color: var(--muted); }
  .stat-card { cursor: pointer; user-select: none; }
  .stat-card.active { border-color: var(--accent, #6c5ce7); box-shadow: 0 0 0 1px var(--accent, #6c5ce7) inset; }
  .controls { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
  .progress-wrap { margin-bottom: 16px; }
  .progress-bar { width: 100%; height: 10px; background: #eee; border-radius: 6px; overflow: hidden; }
  .progress-bar-fill { height: 100%; background: var(--accent, #6c5ce7); transition: width .3s; }
  .progress-text { font-size: 12px; color: var(--muted); margin-top: 6px; }
  table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
  table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; }
  table.admin-table th { color: var(--muted); font-weight: 600; white-space: nowrap; }
  td.preview video { display: block; width: 100px; max-height: 150px; background: #000; border-radius: 4px; }
  .qc-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
  .qc-correct { background: #eef7ee; color: #2e7d32; }
  .qc-incorrect { background: #fbeaea; color: #c0392b; }
  .qc-error { background: #fff8e1; color: #b26a00; }
  .qc-pending { background: #f2f2f2; color: var(--muted); }
  td.note { max-width: 320px; color: var(--muted); }
  td.prompt { max-width: 320px; color: var(--muted); }
  .empty-hint { color: var(--muted); font-size: 13px; padding: 20px 0; text-align: center; }
</style>
</head>
<body>
<header class="topbar">
  <h1>跳舞视频质检</h1>
  <div class="spacer"></div>
  <a href="admin-dance-library.html">跳舞直播间片库</a>
  <a href="admin.html">视频库</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap">
  <div class="stat-row" id="statRow"></div>

  <div class="controls">
    <button id="checkPendingBtn">批量检查待检查项</button>
    <button id="recheckAllBtn" class="secondary">重新检查全部(含已查过的)</button>
    <button id="cancelBtn" class="danger" hidden>停止</button>
  </div>

  <div class="progress-wrap" id="progressWrap" hidden>
    <div class="progress-bar"><div class="progress-bar-fill" id="progressFill" style="width:0%"></div></div>
    <div class="progress-text" id="progressText"></div>
  </div>

  <div style="overflow-x:auto;">
    <table class="admin-table">
      <thead>
        <tr>
          <th>预览</th>
          <th>人物 × 衣服</th>
          <th>结果</th>
          <th>说明</th>
          <th>生成 Prompt</th>
        </tr>
      </thead>
      <tbody id="resultsBody"></tbody>
    </table>
  </div>
  <div class="empty-hint" id="emptyHint" hidden>还没有跑过质检</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 library = [];
let characters = [];
let garments = [];
let currentBatchId = null;
let pollTimer = null;
let currentFilter = "all"; // all | pending | correct | incorrect
let lastJob = null;

async function loadStats() {
  const [libRes, charRes, garmentRes] = await Promise.all([fetch("/api/library?sort=newest"), fetch("/api/admin/characters"), fetch("/api/admin/garments")]);
  library = (await libRes.json()).filter((c) => c.channel === "dance");
  characters = await charRes.json();
  garments = await garmentRes.json();
  renderStats();
  renderResults(null); // seed/refresh the table from whatever's already on each clip's own record
}

function renderStats() {
  const counts = { pending: 0, correct: 0, incorrect: 0 };
  library.forEach((c) => counts[c.qcStatus || "pending"]++);
  const cards = [
    { filter: "all", extraClass: "", num: library.length, label: "总视频数" },
    { filter: "pending", extraClass: "pending", num: counts.pending, label: "待检查" },
    { filter: "correct", extraClass: "correct", num: counts.correct, label: "正确" },
    { filter: "incorrect", extraClass: "incorrect", num: counts.incorrect, label: "有问题" },
  ];
  document.getElementById("statRow").innerHTML = cards
    .map(
      (c) => `
    <div class="stat-card ${c.extraClass} ${currentFilter === c.filter ? "active" : ""}" data-filter="${c.filter}">
      <div class="num">${c.num}</div><div class="label">${c.label}</div>
    </div>`
    )
    .join("");
  document.querySelectorAll("#statRow [data-filter]").forEach((el) =>
    el.addEventListener("click", () => {
      currentFilter = currentFilter === el.dataset.filter ? "all" : el.dataset.filter;
      renderStats();
      renderResults(lastJob);
    })
  );
}

function nameOf(list, id) {
  const item = list.find((x) => x.id === id);
  return item ? item.name : "-";
}
function videoHref(id) {
  return `/lib/video/${id}`;
}

// Every clip in the library, tagged with its current qcStatus (defaulting to
// "pending" for ones never checked), overlaid with a live batch job's own
// results so the table isn't empty just because no batch has run yet in
// THIS page load, and shows in-flight status while a batch is running.
function allResults(job) {
  const merged = new Map(library.map((c) => [c.id, { clipId: c.id, qcStatus: c.qcStatus || "pending", qcNote: c.qcNote }]));
  if (job) for (const r of job.results) merged.set(r.clipId, { ...merged.get(r.clipId), ...r });
  return [...merged.values()];
}

function rowRank(r) {
  return r.error ? 0 : r.qcStatus === "incorrect" ? 1 : r.qcStatus === "correct" ? 2 : 3;
}

function renderResults(job) {
  let rows = allResults(job);
  if (currentFilter !== "all") {
    rows = rows.filter((r) => (currentFilter === "incorrect" ? r.qcStatus === "incorrect" || r.error : r.qcStatus === currentFilter));
  }
  rows.sort((a, b) => rowRank(a) - rowRank(b));

  const emptyHint = document.getElementById("emptyHint");
  emptyHint.hidden = rows.length > 0;
  emptyHint.textContent = currentFilter === "all" ? "还没有跑过质检" : "没有符合筛选条件的视频";
  document.getElementById("resultsBody").innerHTML = rows
    .map((r) => {
      const clip = library.find((c) => c.id === r.clipId);
      const label = clip ? `${escapeHtml(nameOf(characters, clip.characterId))} × ${escapeHtml(nameOf(garments, clip.garmentId))}` : r.clipId;
      const resultBadge = r.error
        ? `<span class="qc-badge qc-error">检查失败</span>`
        : r.qcStatus === "pending"
        ? `<span class="qc-badge qc-pending">待检查</span>`
        : `<span class="qc-badge qc-${r.qcStatus}">${r.qcStatus === "correct" ? "正确" : "有问题"}</span>`;
      const note = r.error || r.qcNote || "";
      const fullPrompt = (clip && clip.fullPrompt) || "";
      const promptCell = fullPrompt ? `${escapeHtml(fullPrompt.slice(0, 100))}${fullPrompt.length > 100 ? "…" : ""}` : "-";
      return `
      <tr>
        <td class="preview">${clip ? `<video controls preload="none" src="${videoHref(clip.id)}"></video>` : "-"}</td>
        <td>${label}</td>
        <td>${resultBadge}</td>
        <td class="note" title="${escapeHtml(note)}">${escapeHtml(note)}</td>
        <td class="prompt" title="${escapeHtml(fullPrompt)}">${promptCell}</td>
      </tr>`;
    })
    .join("");
}

function setRunning(running) {
  document.getElementById("checkPendingBtn").disabled = running;
  document.getElementById("recheckAllBtn").disabled = running;
  document.getElementById("cancelBtn").hidden = !running;
  document.getElementById("progressWrap").hidden = !running && !currentBatchId;
}

async function pollBatch() {
  if (!currentBatchId) return;
  const job = await fetch(`/api/admin/dance-qc/batch/${currentBatchId}`).then((r) => r.json());
  lastJob = job;
  const pct = job.total ? Math.round((job.checked / job.total) * 100) : 100;
  document.getElementById("progressFill").style.width = `${pct}%`;
  document.getElementById("progressText").textContent = `已检查 ${job.checked} / ${job.total}`;
  renderResults(job);
  if (job.status === "done") {
    clearInterval(pollTimer);
    pollTimer = null;
    setRunning(false);
    await loadStats();
    return;
  }
  setRunning(true);
}

async function startBatch(clipIds) {
  if (!clipIds.length) {
    alert("没有符合条件的视频");
    return;
  }
  const res = await fetch("/api/admin/dance-qc/batch", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ clipIds }),
  });
  const data = await res.json();
  currentBatchId = data.batchId;
  document.getElementById("progressWrap").hidden = false;
  setRunning(true);
  if (pollTimer) clearInterval(pollTimer);
  pollTimer = setInterval(pollBatch, 2000);
  pollBatch();
}

document.getElementById("checkPendingBtn").addEventListener("click", () => {
  const pending = library.filter((c) => (c.qcStatus || "pending") === "pending").map((c) => c.id);
  startBatch(pending);
});
document.getElementById("recheckAllBtn").addEventListener("click", () => {
  if (!confirm(`将重新检查全部 ${library.length} 条视频(包括已经查过的),会重新消耗 AI 调用次数,确定吗?`)) return;
  startBatch(library.map((c) => c.id));
});
document.getElementById("cancelBtn").addEventListener("click", async () => {
  if (!currentBatchId) return;
  await fetch(`/api/admin/dance-qc/batch/${currentBatchId}/cancel`, { method: "POST" });
});

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