jonghhhh/deliberation_coach
0
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,"&").replace(/</g,"<").replace(/>/g,">");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 