admin-characters.html 9.82 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; }
  .thumb { width: 56px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
  .add-card { margin-bottom: 16px; }
  .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; align-items: start; }
  .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
  .default-badge { display: inline-block; padding: 2px 6px; font-size: 11px; border-radius: 4px; background: #eef7ee; color: #2e7d32; }
</style>
</head>
<body>
<header class="topbar">
  <h1>人物库管理</h1>
  <div class="spacer"></div>
  <a href="admin.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="card add-card">
    <div class="field"><label>新增人物</label></div>
    <div class="row-4">
      <input type="text" id="newName" placeholder="名称" />
      <input type="file" id="newImageFile" accept="image/*" />
      <input type="text" id="newTags" placeholder="标签(逗号分隔,可选)" />
      <label style="display:flex;align-items:center;gap:6px;font-size:12px;">
        <input type="checkbox" id="newIsDefault" /> 设为默认
      </label>
    </div>
    <div class="field" style="margin-top:8px;">
      <textarea id="newAppearanceDescriptor" placeholder="人物外观特征(发色/发型/五官/体态,2-3 条具体描述,如:shoulder-length wavy black hair, delicate features, a slender build)" 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="搜索名称 / 标签..." />
  </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>
          <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;
}

// T31: totalSegments/remainingSegments are only ever non-null for
// source:"user_paid" characters (mode 3, T35) — admin_seed characters like
// the default one stay null forever (unlimited plays, not a purchased quota).
function segmentsLabel(c) {
  if (c.totalSegments == null) return "-";
  return `${c.remainingSegments ?? 0}/${c.totalSegments}`;
}

async function uploadImage(file) {
  const fd = new FormData();
  fd.append("file", file);
  const res = await fetch("/api/admin/upload", { 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/characters");
  items = await res.json();
  render();
}

function filtered() {
  const q = document.getElementById("searchInput").value.trim().toLowerCase();
  if (!q) return items;
  return items.filter((c) => c.name.toLowerCase().includes(q) || (c.tags || []).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((c) => {
      if (c.id === editingId) {
        return `
        <tr data-id="${c.id}">
          <td><img class="thumb" src="${escapeHtml(c.imageUrl)}" /></td>
          <td class="name"><input data-field="name" value="${escapeHtml(c.name)}" /></td>
          <td class="desc"><textarea data-field="appearanceDescriptor">${escapeHtml(c.appearanceDescriptor || "")}</textarea></td>
          <td class="name"><input data-field="tags" value="${escapeHtml((c.tags || []).join(","))}" /></td>
          <td><input type="checkbox" data-field="isDefault" ${c.isDefault ? "checked" : ""} /></td>
          <td>${c.source}</td>
          <td>${c.status}</td>
          <td>${segmentsLabel(c)}</td>
          <td>${new Date(c.createdAt).toLocaleDateString()}</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><img class="thumb" src="${escapeHtml(c.imageUrl)}" /></td>
          <td class="name">${escapeHtml(c.name)}</td>
          <td class="desc" title="${escapeHtml(c.appearanceDescriptor || "")}">${escapeHtml(c.appearanceDescriptor || "")}</td>
          <td class="name">${escapeHtml((c.tags || []).join(", "))}</td>
          <td>${c.isDefault ? '<span class="default-badge">默认</span>' : ""}</td>
          <td>${c.source}</td>
          <td>${c.status}</td>
          <td>${segmentsLabel(c)}</td>
          <td>${new Date(c.createdAt).toLocaleDateString()}</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) => {
        if (el.type === "checkbox") body[el.dataset.field] = el.checked;
        else if (el.dataset.field === "tags") body.tags = el.value.split(",").map((s) => s.trim()).filter(Boolean);
        else body[el.dataset.field] = el.value;
      });
      const res = await fetch(`/api/admin/characters/${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/characters/${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("newImageFile").files[0];
  const appearanceDescriptor = document.getElementById("newAppearanceDescriptor").value.trim();
  const tags = document.getElementById("newTags").value.split(",").map((s) => s.trim()).filter(Boolean);
  const isDefault = document.getElementById("newIsDefault").checked;
  if (!name || !file || !appearanceDescriptor) {
    errorBox.hidden = false;
    errorBox.textContent = "名称、图片、外观描述都必填";
    return;
  }
  try {
    const imageUrl = await uploadImage(file);
    const res = await fetch("/api/admin/characters", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, imageUrl, appearanceDescriptor, tags, isDefault }),
    });
    if (res.ok) {
      document.getElementById("newName").value = "";
      document.getElementById("newImageFile").value = "";
      document.getElementById("newTags").value = "";
      document.getElementById("newAppearanceDescriptor").value = "";
      document.getElementById("newIsDefault").checked = false;
      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>