admin-dance-refs.html 9.99 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 { color: var(--muted); font-weight: 600; white-space: nowrap; }
  table.admin-table td.name { max-width: 160px; }
  table.admin-table td.desc { max-width: 320px; 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.thumb { max-width: 120px; max-height: 180px; width: auto; height: auto; object-fit: contain; display: block; border-radius: 4px; border: 1px solid var(--border); background: #000; }
  .add-card { margin-bottom: 16px; }
  .row-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 100px; gap: 14px; align-items: start; }
  .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
  .inactive-badge { display: inline-block; padding: 2px 6px; font-size: 11px; border-radius: 4px; background: #fbeaea; color: #c0392b; }
  .required-hint { color: #c0392b; font-size: 11px; }
</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-batch-gen.html">批量生成</a>
  <a href="admin-dressgenlist.html">人衣图生成</a>
  <a href="admin-dance-library.html">跳舞直播间片库</a>
  <a href="admin-dance-qc.html">质检</a>
  <a href="live.html">← 返回直播频道</a>
</header>

<main class="admin-wrap">
  <div class="card add-card">
    <div class="field"><label>新增参考跳舞视频</label></div>
    <div class="row-4">
      <input type="text" id="newName" placeholder="名称" />
      <input type="file" id="newVideoFile" accept="video/mp4" />
      <input type="text" id="newStyleTags" placeholder="风格标签(逗号分隔,可选)" />
      <label style="display:flex;align-items:center;gap:6px;font-size:12px;">
        <input type="checkbox" id="newIsActive" checked /> 上架
      </label>
    </div>
    <div class="field" style="margin-top:8px;">
      <textarea id="newMotionDescriptor" placeholder="动作描述(必填——如:拉丁风格摆胯+举臂旋转 / a lively hip-hop inspired routine with hip sways and raised-arm turns)" style="min-height:60px;"></textarea>
      <div class="required-hint">动作描述是必填项:这条视频一旦录入,后续所有引用它生成的视频都复用这段描述,不能留空事后补</div>
    </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="搜索名称 / 标签..." />
  </div>
  <div class="count-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>
        </tr>
      </thead>
      <tbody id="tbody"></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;
};

let items = [];
let editingId = null;

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

async function uploadVideo(file) {
  const fd = new FormData();
  fd.append("file", file);
  const res = await fetch("/api/admin/upload-video", { method: "POST", body: fd });
  if (!res.ok) throw new Error((await res.json()).error || "上传失败");
  return (await res.json()).url;
}

async function load() {
  const res = await fetch("/api/admin/dance-refs");
  items = await res.json();
  render();
}

function filtered() {
  const q = document.getElementById("searchInput").value.trim().toLowerCase();
  if (!q) return items;
  return items.filter((d) => d.name.toLowerCase().includes(q) || (d.styleTags || []).join(",").toLowerCase().includes(q));
}

function render() {
  const rows = filtered();
  document.getElementById("countHint").textContent = `共 ${items.length} 条,当前筛选 ${rows.length} 条`;

  const tbody = document.getElementById("tbody");
  tbody.innerHTML = rows
    .map((d) => {
      if (d.id === editingId) {
        return `
        <tr data-id="${d.id}">
          <td><video class="thumb" src="${escapeHtml(d.videoUrl)}" muted></video></td>
          <td class="name"><input data-field="name" value="${escapeHtml(d.name)}" /></td>
          <td class="desc"><textarea data-field="motionDescriptor">${escapeHtml(d.motionDescriptor || "")}</textarea></td>
          <td class="name"><input data-field="styleTags" value="${escapeHtml((d.styleTags || []).join(","))}" /></td>
          <td>${d.copyrightSource}</td>
          <td><input type="checkbox" data-field="isActive" ${d.isActive ? "checked" : ""} /></td>
          <td>${new Date(d.createdAt).toLocaleDateString()}</td>
          <td class="row-actions">
            <button data-save="${d.id}">保存</button>
            <button class="secondary" data-cancel="1">取消</button>
          </td>
        </tr>`;
      }
      return `
        <tr data-id="${d.id}">
          <td><video class="thumb" src="${escapeHtml(d.videoUrl)}" muted controls></video></td>
          <td class="name">${escapeHtml(d.name)}</td>
          <td class="desc" title="${escapeHtml(d.motionDescriptor || "")}">${escapeHtml(d.motionDescriptor || "")}</td>
          <td class="name">${escapeHtml((d.styleTags || []).join(", "))}</td>
          <td>${d.copyrightSource}</td>
          <td>${d.isActive ? "上架" : '<span class="inactive-badge">下架</span>'}</td>
          <td>${new Date(d.createdAt).toLocaleDateString()}</td>
          <td class="row-actions">
            <button class="secondary" data-edit="${d.id}">编辑</button>
            <button class="danger" data-del="${d.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) => {
        if (el.type === "checkbox") body[el.dataset.field] = el.checked;
        else if (el.dataset.field === "styleTags") body.styleTags = el.value.split(",").map((s) => s.trim()).filter(Boolean);
        else body[el.dataset.field] = el.value;
      });
      if ("motionDescriptor" in body && !body.motionDescriptor.trim()) {
        alert("动作描述不能清空");
        return;
      }
      const res = await fetch(`/api/admin/dance-refs/${b.dataset.save}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (res.ok) {
        editingId = null;
        await load();
      }
    })
  );
  tbody.querySelectorAll("[data-del]").forEach((b) =>
    b.addEventListener("click", async () => {
      if (!confirm("确认删除这条参考视频?")) return;
      const res = await fetch(`/api/admin/dance-refs/${b.dataset.del}`, { method: "DELETE" });
      if (res.ok) await load();
    })
  );
}

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

document.getElementById("addBtn").addEventListener("click", async () => {
  const errorBox = document.getElementById("addError");
  errorBox.hidden = true;
  const name = document.getElementById("newName").value.trim();
  const file = document.getElementById("newVideoFile").files[0];
  const motionDescriptor = document.getElementById("newMotionDescriptor").value.trim();
  const styleTags = document.getElementById("newStyleTags").value.split(",").map((s) => s.trim()).filter(Boolean);
  const isActive = document.getElementById("newIsActive").checked;
  if (!name || !file || !motionDescriptor) {
    errorBox.hidden = false;
    errorBox.textContent = "名称、视频、动作描述都必填";
    return;
  }
  try {
    const videoUrl = await uploadVideo(file);
    const res = await fetch("/api/admin/dance-refs", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, videoUrl, motionDescriptor, styleTags, isActive }),
    });
    if (res.ok) {
      document.getElementById("newName").value = "";
      document.getElementById("newVideoFile").value = "";
      document.getElementById("newStyleTags").value = "";
      document.getElementById("newMotionDescriptor").value = "";
      document.getElementById("newIsActive").checked = true;
      await load();
    } else {
      errorBox.hidden = false;
      errorBox.textContent = (await res.json()).error || "新增失败";
    }
  } catch (e) {
    errorBox.hidden = false;
    errorBox.textContent = e.message;
  }
});

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