admin.html 14.5 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; }
  .admin-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
  .admin-toolbar input[type="search"] { flex: 1; min-width: 220px; }
  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 { cursor: pointer; user-select: none; color: var(--muted); font-weight: 600; white-space: nowrap; }
  table.admin-table th:hover { color: var(--text); }
  table.admin-table td.name { max-width: 220px; }
  table.admin-table td.prompt { max-width: 360px; color: var(--muted); }
  table.admin-table td input, table.admin-table td textarea { width: 100%; font-size: 12px; }
  table.admin-table td textarea { min-height: 50px; }
  .row-actions { display: flex; gap: 6px; white-space: nowrap; }
  .row-actions button { padding: 3px 8px; font-size: 12px; }
  .video-link { font-size: 12px; word-break: break-all; }
  .add-card { margin-bottom: 16px; }
  .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; }
  .sort-arrow { font-size: 10px; }
  .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
  .channel-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
  .channel-tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
  #previewDialog { padding: 0; border: none; border-radius: var(--radius); background: #000; }
  #previewDialog::backdrop { background: rgba(0,0,0,.6); }
  #previewDialog video { display: block; max-width: 80vw; max-height: 80vh; }
</style>
</head>
<body>
<header class="topbar">
  <h1>视频库管理</h1>
  <div class="spacer"></div>
  <a href="admin-characters.html">人物库</a>
  <a href="admin-garments.html">衣服库</a>
  <a href="admin-dance-refs.html">参考跳舞视频库</a>
  <a href="admin-batch-gen.html">批量生成</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap">
  <div class="channel-tabs">
    <button id="tabVideo" class="secondary">视频直播间</button>
    <button id="tabDance" class="secondary">跳舞直播间</button>
  </div>

  <div class="card add-card">
    <div class="field"><label>新增片段(手动指定视频地址)</label></div>
    <div class="row-4">
      <input type="text" id="newPrompt" placeholder="名称(中文)" />
      <input type="text" id="newPromptEn" placeholder="名称(英文,可选)" />
      <input type="text" id="newVideoUrl" placeholder="视频文件地址(可访问的 mp4 URL)" />
      <input type="number" id="newDuration" placeholder="时长(秒)" />
    </div>
    <div class="field" style="margin-top:8px;">
      <label>归属直播间</label>
      <select id="newChannel">
        <option value="video">视频直播间</option>
        <option value="dance">跳舞直播间</option>
      </select>
    </div>
    <div class="field" style="margin-top:8px;">
      <textarea id="newFullPrompt" placeholder="完整 prompt(可选,留空则用名称)" style="min-height:60px;"></textarea>
    </div>
    <button id="addBtn" style="margin-top:8px;">新增</button>
    <div id="addError" class="error-box" hidden></div>
  </div>

  <div class="admin-toolbar">
    <input type="search" id="searchInput" placeholder="搜索名称 / prompt / id..." />
    <select id="sourceFilter">
      <option value="">全部来源</option>
      <option value="h3console">h3console</option>
      <option value="manual">manual</option>
    </select>
  </div>
  <div class="count-hint" id="countHint"></div>

  <div style="overflow-x:auto;">
    <table class="admin-table">
      <thead>
        <tr>
          <th data-sort="prompt">名称(中)</th>
          <th data-sort="promptEn">名称(英)</th>
          <th data-sort="fullPrompt">Prompt</th>
          <th>视频地址</th>
          <th>预览</th>
          <th>直播间</th>
          <th>溯源(人物×衣服×参考视频)</th>
          <th data-sort="duration">时长</th>
          <th data-sort="likes">点赞</th>
          <th data-sort="source">来源</th>
          <th data-sort="createdAt">创建时间</th>
          <th data-sort="lastPlayedAt">上次播放</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody id="tbody"></tbody>
    </table>
  </div>
</main>

<dialog id="previewDialog">
  <video id="previewVideo" controls autoplay></video>
</dialog>

<script>
// Redirect to login whenever any /api/admin/* call comes back 401 (T6 auth
// gate) — a single interceptor instead of touching every fetch call site.
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;
};

let library = [];
let characters = [];
let garments = [];
let danceRefs = [];
let editingId = null;
let sortKey = "createdAt";
let sortDir = -1;
// 视频直播间(live.html) / 跳舞直播间(dance.html) 是两条独立的产品线,
// 素材各自的排播池互不共享(architecture.md 2.1),所以后台也分开管理。
let activeChannel = "video";

function inChannel(c) {
  return activeChannel === "dance" ? c.channel === "dance" : c.channel !== "dance";
}

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

async function loadLibrary() {
  const [libRes, charRes, garmentRes, danceRefRes] = await Promise.all([
    fetch("/api/library?sort=newest"),
    fetch("/api/admin/characters"),
    fetch("/api/admin/garments"),
    fetch("/api/admin/dance-refs"),
  ]);
  library = await libRes.json();
  characters = await charRes.json();
  garments = await garmentRes.json();
  danceRefs = await danceRefRes.json();
  render();
}

function nameOf(list, id) {
  if (!id) return null;
  const item = list.find((x) => x.id === id);
  return item ? item.name : id;
}

// T10: 一步法下没有 composedImageId/合成图,溯源只有这三个字段(M.7)。
function traceability(c) {
  const characterName = nameOf(characters, c.characterId);
  const garmentName = nameOf(garments, c.garmentId);
  const danceRefName = nameOf(danceRefs, c.refDanceVideoId);
  if (!characterName && !garmentName && !danceRefName) return "-";
  return [characterName, garmentName, danceRefName].map((n) => n || "?").join(" × ");
}

function filteredSorted() {
  const q = document.getElementById("searchInput").value.trim().toLowerCase();
  const source = document.getElementById("sourceFilter").value;
  let rows = library.filter(inChannel);
  if (q) rows = rows.filter((c) => c.prompt.toLowerCase().includes(q) || (c.promptEn || "").toLowerCase().includes(q) || (c.fullPrompt || "").toLowerCase().includes(q) || c.id.toLowerCase().includes(q));
  if (source) rows = rows.filter((c) => c.source === source);
  rows.sort((a, b) => {
    const av = a[sortKey] ?? 0;
    const bv = b[sortKey] ?? 0;
    if (typeof av === "string") return av.localeCompare(bv) * sortDir;
    return (av - bv) * sortDir;
  });
  return rows;
}

function videoHref(c) {
  return `/lib/video/${c.id}`;
}

function render() {
  const rows = filteredSorted();
  const channelTotal = library.filter(inChannel).length;
  document.getElementById("countHint").textContent = `本直播间共 ${channelTotal} 条,当前筛选 ${rows.length} 条`;
  document.getElementById("tabVideo").classList.toggle("active", activeChannel === "video");
  document.getElementById("tabDance").classList.toggle("active", activeChannel === "dance");
  document.querySelectorAll("th[data-sort]").forEach((th) => {
    th.innerHTML = th.textContent.replace(/ ?[▲▼]$/, "") + (th.dataset.sort === sortKey ? ` <span class="sort-arrow">${sortDir === 1 ? "▲" : "▼"}</span>` : "");
  });

  const tbody = document.getElementById("tbody");
  tbody.innerHTML = rows
    .map((c) => {
      if (c.id === editingId) {
        return `
        <tr data-id="${c.id}">
          <td class="name"><input data-field="prompt" value="${escapeHtml(c.prompt)}" /></td>
          <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td>
          <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td>
          <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td>
          <td><button class="secondary" data-preview="${c.id}">▶ 预览</button></td>
          <td>
            <select data-field="channel">
              <option value="video" ${c.channel === "dance" ? "" : "selected"}>视频直播间</option>
              <option value="dance" ${c.channel === "dance" ? "selected" : ""}>跳舞直播间</option>
            </select>
          </td>
          <td>${escapeHtml(traceability(c))}</td>
          <td>${c.duration ?? "-"}s</td>
          <td>${c.likes || 0}</td>
          <td>${c.source}</td>
          <td>${new Date(c.createdAt).toLocaleDateString()}</td>
          <td>${c.lastPlayedAt ? new Date(c.lastPlayedAt).toLocaleString() : "从未"}</td>
          <td class="row-actions">
            <button data-save="${c.id}">保存</button>
            <button class="secondary" data-cancel="1">取消</button>
          </td>
        </tr>`;
      }
      return `
        <tr data-id="${c.id}">
          <td class="name">${escapeHtml(c.prompt)}</td>
          <td class="name">${escapeHtml(c.promptEn || "")}</td>
          <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td>
          <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td>
          <td><button class="secondary" data-preview="${c.id}">▶ 预览</button></td>
          <td>${c.channel === "dance" ? "跳舞直播间" : "视频直播间"}</td>
          <td>${escapeHtml(traceability(c))}</td>
          <td>${c.duration ?? "-"}s</td>
          <td>${c.likes || 0}</td>
          <td>${c.source}</td>
          <td>${new Date(c.createdAt).toLocaleDateString()}</td>
          <td>${c.lastPlayedAt ? new Date(c.lastPlayedAt).toLocaleString() : "从未"}</td>
          <td class="row-actions">
            <button class="secondary" data-edit="${c.id}">编辑</button>
            <button class="danger" data-del="${c.id}">删除</button>
          </td>
        </tr>`;
    })
    .join("");

  tbody.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => { editingId = b.dataset.edit; render(); }));
  tbody.querySelectorAll("[data-cancel]").forEach((b) => b.addEventListener("click", () => { editingId = null; render(); }));
  tbody.querySelectorAll("[data-save]").forEach((b) =>
    b.addEventListener("click", async () => {
      const tr = b.closest("tr");
      const body = {};
      tr.querySelectorAll("[data-field]").forEach((el) => (body[el.dataset.field] = el.value));
      const res = await fetch(`/api/admin/library/${b.dataset.save}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (res.ok) {
        editingId = null;
        await loadLibrary();
      }
    })
  );
  tbody.querySelectorAll("[data-preview]").forEach((b) =>
    b.addEventListener("click", () => {
      previewVideo.src = videoHref(rows.find((c) => c.id === b.dataset.preview));
      previewDialog.showModal();
    })
  );
  tbody.querySelectorAll("[data-del]").forEach((b) =>
    b.addEventListener("click", async () => {
      if (!confirm("确认删除这条片段?(如果来自 H3 Console 会同步从那边删除)")) return;
      const res = await fetch(`/api/admin/library/${b.dataset.del}`, { method: "DELETE" });
      if (res.ok) await loadLibrary();
    })
  );
}

document.querySelectorAll("th[data-sort]").forEach((th) => {
  th.addEventListener("click", () => {
    if (sortKey === th.dataset.sort) sortDir *= -1;
    else {
      sortKey = th.dataset.sort;
      sortDir = -1;
    }
    render();
  });
});

const previewDialog = document.getElementById("previewDialog");
const previewVideo = document.getElementById("previewVideo");
previewDialog.addEventListener("click", (e) => { if (e.target === previewDialog) previewDialog.close(); });
previewDialog.addEventListener("close", () => { previewVideo.pause(); previewVideo.src = ""; });

document.getElementById("searchInput").addEventListener("input", render);
document.getElementById("sourceFilter").addEventListener("change", render);

document.getElementById("tabVideo").addEventListener("click", () => {
  activeChannel = "video";
  document.getElementById("newChannel").value = "video";
  render();
});
document.getElementById("tabDance").addEventListener("click", () => {
  activeChannel = "dance";
  document.getElementById("newChannel").value = "dance";
  render();
});

document.getElementById("addBtn").addEventListener("click", async () => {
  const errorBox = document.getElementById("addError");
  errorBox.hidden = true;
  const prompt = document.getElementById("newPrompt").value.trim();
  const promptEn = document.getElementById("newPromptEn").value.trim();
  const videoUrl = document.getElementById("newVideoUrl").value.trim();
  const fullPrompt = document.getElementById("newFullPrompt").value.trim();
  const duration = Number(document.getElementById("newDuration").value) || null;
  const channel = document.getElementById("newChannel").value;
  if (!prompt || !videoUrl) {
    errorBox.hidden = false;
    errorBox.textContent = "名称和视频地址必填";
    return;
  }
  const res = await fetch("/api/admin/library", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt, promptEn, videoUrl, fullPrompt, duration, channel }),
  });
  if (res.ok) {
    document.getElementById("newPrompt").value = "";
    document.getElementById("newPromptEn").value = "";
    document.getElementById("newVideoUrl").value = "";
    document.getElementById("newFullPrompt").value = "";
    document.getElementById("newDuration").value = "";
    await loadLibrary();
  } else {
    errorBox.hidden = false;
    errorBox.textContent = (await res.json()).error || "新增失败";
  }
});

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