CoolFace
Apppublic

jonghhhh/deliberation_coach

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
admin.html313 linesDownload Raw Back to root
1<!DOCTYPE html>2<html lang="ko">3<head>4<meta charset="utf-8" />5<meta name="viewport" content="width=device-width, initial-scale=1" />6<title>AI 숙의코치 — 관리자</title>7<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />8<style>9  :root{10    --ink:#1d2433; --muted:#5b6472; --paper:#f5f6f8; --card:#fff; --line:#e3e6ea;11    --accent:#2f7d78; --danger:#b5832c; --radius:12px;12  }13  *{box-sizing:border-box}14  html,body{margin:0;background:var(--paper);color:var(--ink);15    font-family:Pretendard,system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.55;}16  .wrap{max-width:1100px;margin:0 auto;padding:18px 18px 80px;}17  h1{font-size:20px;margin:6px 0 2px;}18  h2{font-size:15px;margin:0 0 10px;color:var(--muted);font-weight:600;}19  .sub{color:var(--muted);font-size:13px;margin:0 0 18px;}20  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);21    padding:16px 18px;margin-bottom:16px;}22  .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}23  label{font-size:13px;color:var(--muted);display:block;margin:8px 0 3px;}24  input[type=text],input[type=password]{width:100%;padding:9px 11px;border:1px solid var(--line);25    border-radius:9px;font:inherit;background:#fff;}26  .btn{border:0;background:var(--accent);color:#fff;padding:9px 14px;border-radius:9px;27    font:inherit;cursor:pointer;font-weight:600;}28  .btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line);}29  .btn:disabled{opacity:.5;cursor:default;}30  .btn.small{padding:5px 10px;font-size:13px;}31  table{width:100%;border-collapse:collapse;font-size:13.5px;}32  th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}33  th{color:var(--muted);font-weight:600;cursor:pointer;user-select:none;}34  tbody tr{cursor:pointer;}35  tbody tr:hover{background:#f0f4f4;}36  .pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;border:1px solid var(--line);}37  .pill.completed{background:#e6f0ef;color:#1f5d58;border-color:#cfe3e1;}38  .pill.aborted{background:#f7e7e7;color:#9a3b3b;border-color:#eccaca;}39  .pill.in_progress{background:#eef0f3;color:#5b6472;}40  .muted{color:var(--muted);}41  .err{color:#9a3b3b;font-size:13px;margin-top:8px;}42  .ok{color:#1f5d58;font-size:13px;margin-top:8px;}43  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}44  @media(max-width:640px){.grid2{grid-template-columns:1fr;}}45  /* 상세 모달 */46  .modal-bg{position:fixed;inset:0;background:rgba(20,24,34,.45);display:flex;47    align-items:flex-start;justify-content:center;padding:24px;overflow:auto;z-index:50;}48  .modal{background:#fff;border-radius:var(--radius);max-width:820px;width:100%;padding:20px 22px;}49  .kv{display:grid;grid-template-columns:140px 1fr;gap:4px 12px;font-size:13.5px;margin-bottom:14px;}50  .kv div:nth-child(odd){color:var(--muted);}51  .turn{border-left:3px solid var(--line);padding:4px 0 4px 10px;margin:6px 0;font-size:13.5px;}52  .turn.ai{border-color:var(--accent);}53  .turn.participant{border-color:var(--danger);}54  .turn .who{font-size:12px;color:var(--muted);}55  pre{background:#f5f6f8;border:1px solid var(--line);border-radius:9px;padding:10px;56    overflow:auto;font-size:12.5px;max-height:280px;}57  .sec{font-weight:600;margin:14px 0 6px;font-size:14px;}58</style>59</head>60<body>61<div class="wrap">62  <h1>AI 숙의코치 — 관리자</h1>63  <p class="sub">응답자 현황 · 응답 데이터 확인/다운로드 · 운영 설정 관리</p>64 65  <!-- 로그인 -->66  <div class="card" id="loginCard">67    <h2>관리자 토큰</h2>68    <div class="row" style="align-items:flex-end;">69      <div style="flex:1;min-width:220px;">70        <label>ADMIN_TOKEN (.env 에 설정한 값)</label>71        <input type="password" id="tokenInput" placeholder="토큰 입력" />72      </div>73      <button class="btn" id="loginBtn">접속</button>74    </div>75    <div class="err" id="loginErr"></div>76  </div>77 78  <!-- 메인(로그인 후) -->79  <div id="main" style="display:none;">80    <!-- 설정 -->81    <div class="card">82      <h2>운영 설정</h2>83      <div class="grid2">84        <div>85          <label>MODEL (Gemini 모델명)</label>86          <input type="text" id="model" />87        </div>88        <div>89          <label>RETURN_URL (종료 후 복귀 URL · 비우면 완료화면만)</label>90          <input type="text" id="returnUrl" />91        </div>92      </div>93      <div class="row" style="margin-top:12px;">94        <button class="btn" id="saveSettings">설정 저장</button>95        <span class="muted" id="keyStatus"></span>96      </div>97      <div class="ok" id="settingsMsg"></div>98    </div>99 100    <!-- 현황 -->101    <div class="card">102      <div class="row" style="justify-content:space-between;">103        <h2 style="margin:0;">응답자 현황 (<span id="count">0</span>명)</h2>104        <div class="row">105          <button class="btn ghost small" id="refresh">새로고침</button>106          <button class="btn small" id="dlCsv">요약 CSV 다운로드</button>107        </div>108      </div>109      <div style="overflow:auto;margin-top:12px;">110        <table id="tbl">111          <thead><tr>112            <th data-k="pid">pid</th>113            <th data-k="completion">상태</th>114            <th data-k="criteria_done">기준완료</th>115            <th data-k="final_choice">최종선택</th>116            <th data-k="confidence">확신도</th>117            <th data-k="turns">대화수</th>118            <th data-k="duration_sec">소요(초)</th>119            <th data-k="saved_at">저장시각</th>120            <th>다운로드</th>121          </tr></thead>122          <tbody id="tbody"></tbody>123        </table>124      </div>125      <div class="err" id="listErr"></div>126    </div>127  </div>128</div>129 130<div id="modalRoot"></div>131 132<script>133const $ = (id) => document.getElementById(id);134let TOKEN = "";135let ROWS = [];136let sortKey = "saved_at", sortDir = -1;137 138const esc = (s) => String(s == null ? "" : s)139  .replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");140 141async function api(path, opts = {}) {142  const headers = Object.assign({ "X-Admin-Token": TOKEN }, opts.headers || {});143  const r = await fetch(path, Object.assign({}, opts, { headers }));144  if (!r.ok) {145    let msg = "HTTP " + r.status;146    try { const j = await r.json(); if (j.detail) msg = j.detail; } catch (e) {}147    throw new Error(msg);148  }149  return r;150}151 152async function login() {153  TOKEN = $("tokenInput").value.trim();154  $("loginErr").textContent = "";155  if (!TOKEN) { $("loginErr").textContent = "토큰을 입력하세요."; return; }156  try {157    const s = await (await api("/api/admin/settings")).json();158    sessionStorage.setItem("admin_token", TOKEN);159    $("loginCard").style.display = "none";160    $("main").style.display = "block";161    fillSettings(s);162    await loadList();163  } catch (e) {164    $("loginErr").textContent = "접속 실패: " + e.message;165  }166}167 168function fillSettings(s) {169  $("model").value = s.model || "";170  $("returnUrl").value = s.return_url || "";171  $("keyStatus").textContent =172    (s.gemini_key_set ? "✓ GEMINI_API_KEY 설정됨" : "⚠ GEMINI_API_KEY 없음(폴백 동작)")173    + " · 저장폴더: " + (s.data_dir || "");174}175 176async function saveSettings() {177  $("settingsMsg").textContent = "";178  try {179    const body = { model: $("model").value.trim(), return_url: $("returnUrl").value.trim() };180    await api("/api/admin/settings", {181      method: "POST",182      headers: { "Content-Type": "application/json" },183      body: JSON.stringify(body),184    });185    $("settingsMsg").textContent = "저장되었습니다.";186    setTimeout(() => ($("settingsMsg").textContent = ""), 2500);187  } catch (e) {188    $("settingsMsg").textContent = "";189    $("settingsMsg").className = "err";190    $("settingsMsg").textContent = "저장 실패: " + e.message;191  }192}193 194async function loadList() {195  $("listErr").textContent = "";196  try {197    const j = await (await api("/api/admin/list")).json();198    ROWS = j.rows || [];199    $("count").textContent = j.count || 0;200    renderTable();201  } catch (e) {202    $("listErr").textContent = "목록 로드 실패: " + e.message;203  }204}205 206function renderTable() {207  const rows = ROWS.slice().sort((a, b) => {208    const x = a[sortKey], y = b[sortKey];209    if (x === y) return 0;210    return (x > y ? 1 : -1) * sortDir;211  });212  const tb = $("tbody");213  tb.innerHTML = "";214  for (const r of rows) {215    const tr = document.createElement("tr");216    const st = esc(r.completion || "in_progress");217    tr.innerHTML =218      `<td>${esc(r.pid)}</td>` +219      `<td><span class="pill ${st}">${st}</span></td>` +220      `<td>${r.criteria_done}/${r.criteria_total}</td>` +221      `<td>${esc(r.final_choice)}</td>` +222      `<td>${esc(r.confidence)}</td>` +223      `<td>${r.turns}</td>` +224      `<td>${r.duration_sec}</td>` +225      `<td class="muted">${esc(r.saved_at)}</td>` +226      `<td><button class="btn ghost small" data-dl="${esc(r.pid)}">JSON</button></td>`;227    tr.addEventListener("click", (ev) => {228      if (ev.target.dataset.dl) return;       // 다운로드 버튼은 행 클릭과 분리229      showDetail(r.pid);230    });231    tb.appendChild(tr);232  }233  tb.querySelectorAll("[data-dl]").forEach((b) =>234    b.addEventListener("click", () => downloadPid(b.dataset.dl)));235}236 237function downloadPid(pid) {238  // 토큰을 쿼리로 실어 새 탭에서 받기(헤더를 못 쓰는 직접 다운로드용)239  const url = "/api/admin/download/" + encodeURIComponent(pid) + "?token=" + encodeURIComponent(TOKEN);240  window.open(url, "_blank");241}242 243function downloadCsv() {244  window.open("/api/admin/summary.csv?token=" + encodeURIComponent(TOKEN), "_blank");245}246 247async function showDetail(pid) {248  let rec;249  try {250    rec = await (await api("/api/admin/record/" + encodeURIComponent(pid))).json();251  } catch (e) { alert("상세 로드 실패: " + e.message); return; }252 253  const sess = rec.session || {}, out = rec.output || {};254  const crit = (rec.criteria || []).map((c) =>255    `<tr><td>${esc(c.name)}</td><td>${esc(c.judgment_initial)}</td>` +256    `<td>${esc(c.judgment_final)}</td><td>${esc(c.reason)}</td><td>${esc(c.weighing)}</td></tr>`257  ).join("");258  const turns = (rec.transcript || []).map((t) =>259    `<div class="turn ${esc(t.role)}"><div class="who">#${t.turn} · ${esc(t.role)}` +260    `${t.criterion ? " · " + esc(t.criterion) : ""} · ${esc(t.ts)}</div>${esc(t.text)}</div>`261  ).join("");262 263  $("modalRoot").innerHTML =264    `<div class="modal-bg" id="mbg"><div class="modal">265      <div class="row" style="justify-content:space-between;">266        <h2 style="margin:0;">응답자 상세 — ${esc(rec.pid)}</h2>267        <div class="row">268          <button class="btn ghost small" id="mDl">JSON 다운로드</button>269          <button class="btn small" id="mClose">닫기</button>270        </div>271      </div>272      <div class="kv" style="margin-top:14px;">273        <div>상태</div><div>${esc(sess.completion)}</div>274        <div>시작</div><div>${esc(sess.started_at)}</div>275        <div>종료</div><div>${esc(sess.ended_at)}</div>276        <div>소요(초)</div><div>${esc(sess.duration_sec)}</div>277        <div>최종선택</div><div>${esc(out.final_choice)} (확신도 ${esc(out.confidence)})</div>278      </div>279      <div class="sec">기준별 판단</div>280      <table><thead><tr><th>기준</th><th>초기</th><th>재평가</th><th>이유</th><th>저울질</th></tr></thead>281        <tbody>${crit || '<tr><td colspan="5" class="muted">없음</td></tr>'}</tbody></table>282      <div class="sec">대화 메시지 (${(rec.transcript || []).length}턴)</div>283      <div>${turns || '<span class="muted">없음</span>'}</div>284      <div class="sec">전체 원본 JSON</div>285      <pre>${esc(JSON.stringify(rec, null, 2))}</pre>286    </div></div>`;287  $("mClose").addEventListener("click", () => ($("modalRoot").innerHTML = ""));288  $("mbg").addEventListener("click", (e) => { if (e.target.id === "mbg") $("modalRoot").innerHTML = ""; });289  $("mDl").addEventListener("click", () => downloadPid(rec.pid));290}291 292// 이벤트 바인딩293$("loginBtn").addEventListener("click", login);294$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); });295$("saveSettings").addEventListener("click", saveSettings);296$("refresh").addEventListener("click", loadList);297$("dlCsv").addEventListener("click", downloadCsv);298document.querySelectorAll("th[data-k]").forEach((th) =>299  th.addEventListener("click", () => {300    const k = th.dataset.k;301    if (sortKey === k) sortDir *= -1; else { sortKey = k; sortDir = 1; }302    renderTable();303  }));304 305// 세션에 저장된 토큰이 있으면 자동 로그인 시도306(function () {307  const t = sessionStorage.getItem("admin_token");308  if (t) { $("tokenInput").value = t; login(); }309})();310</script>311</body>312</html>313