'use strict'; const $ = (id) => document.getElementById(id); const fmt = (n) => Math.round(n || 0).toLocaleString(uiLocale()); const usd = (n) => '$' + (n || 0).toFixed(3); let cashUnit = '원'; // v1.4.1 캐시 표시 단위 (설정을 불러오면 바뀜) const krw = (n) => { const v = Math.round(n || 0).toLocaleString(uiLocale()); return cashUnit === '원' ? TF('{1}원', v) : `${v}${/^[가-힣]+$/.test(cashUnit) ? '' : ' '}${cashUnit}`; }; // v1.3.10 통화 단위 번역, v1.4.1 관리자 지정 단위 const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''); const KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' }; const MODE = { ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '기본', import: '사이트 관리(가져옴)', copy: '사이트 복사' }; const TIER = { basic: '기본', standard: '중급', premium: '고급' }; const STATUS = { running: '실행 중', deploying: '배포 중', 'waiting-credit': '캐시 대기', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김', idle: '대기' }; const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원' }; let overview = null, es = null, me = null, curSite = null, billingData = null, curBillUser = null, settingsData = null, siteFilter = 'all'; const DS = { pending: '배포 대기중', deployed: '발행 완료', working: '작업 중', deploying: '배포 중', empty: '완성 전', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; // v1.6.31 (요구 14) 단계 구분 const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본' }; /** * 관리자 API 호출. * v1.3.0 수정: 1.1.x~1.2.0 은 본문이 FormData(파일 업로드)여도 Content-Type 을 application/json 으로 고정해 * 브라우저가 multipart 경계(boundary)를 붙이지 못했고, 서버가 파일을 JSON 으로 읽으려다 "JSON 파싱 실패" 를 냈다 * (언어 파일·ZIP 업로드가 모두 실패한 원인). FormData 일 때는 헤더를 비워 브라우저가 multipart 헤더를 만들게 한다. */ /** * v1.6.31 (요구 4): 서비스 API 는 세션 쿠키만으로는 쓸 수 없고, 출처가 이 화면(Referer)이거나 X-WM-CSRF 헤더가 있어야 한다. * 토큰은 /api/auth/csrf 가 "출처가 빌더·관리자 화면인 요청" 에만 준다 — 같은 주소에서 열린 생성 사이트 페이지는 받을 수 없다. */ let _csrf = null; let _csrfP = null; async function csrf() { if (_csrf) return _csrf; if (!_csrfP) _csrfP = fetch('/api/auth/csrf', { headers: { 'X-UI-Lang': (window.I18N && window.I18N.lang) || 'ko' } }).then((r) => r.ok ? r.json() : {}).then((b) => { _csrf = (b && b.csrf) || ''; return _csrf; }).catch(() => ''); return _csrfP; } async function api(path, opt = {}) { const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; if (!/^\/api\/auth\/csrf/.test(path)) { const tok = await csrf(); if (tok) headers['X-WM-CSRF'] = tok; } const r = await fetch(path, { headers, ...opt }); const b = await r.json().catch(() => ({})); if (r.status === 401) { location.href = '/login'; throw new Error('로그인이 필요합니다'); } if (r.status === 403) { document.body.innerHTML = '
관리자만 접근할 수 있습니다.
'; throw new Error('403'); } if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; } function td(tr, text, cls) { const c = document.createElement('td'); if (text instanceof Node) c.appendChild(text); else c.textContent = text == null ? '' : text; if (cls) c.className = cls; tr.appendChild(c); return c; } function mkBtn(label, cls, fn) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'ghost'; b.textContent = label; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; } function showTab(name) { document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('hidden', t.id !== 'tab-' + name)); document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name)); if (es && name !== 'detail') { es.close(); es = null; } } /** * 탭별 데이터 불러오기 (v1.3.2). 탭을 열 때와 "새로고침" 때 같은 함수를 쓴다. * sites 는 loadOverview 가 그리므로 별도 함수가 없다. detail 은 열려 있는 사이트 상세를 다시 연다. */ const TAB_LOADERS = { templates: () => loadTemplates(), search: () => loadSearchRegs(), feedback: () => loadFeedback(), i18n: () => loadI18n(), errors: () => loadErrors(), billing: () => loadBilling(), settings: () => loadSettings(), recovery: () => loadRecovery(), audit: () => loadAudit() /* v1.6.31 */, ai: () => loadSettings() /* v1.5.16 AI 연결 탭: 인증·사용할 Claude 모델·제공자 — 설정과 같은 응답을 쓴다 */, deploy: () => loadTargets(), mail: () => loadMailSys(), theme: () => loadTheme(), detail: () => (curSite ? openSite(curSite.id || curSite) : null) }; let activeAdminTab = 'sites'; /** 탭 열기: 화면 전환 + 데이터 불러오기 + 주소의 #탭 기록(브라우저 새로고침에도 같은 탭 유지) */ function openTab(name) { activeAdminTab = name; showTab(name); try { history.replaceState(null, '', name === 'sites' ? location.pathname : '#' + name); } catch (_) {} const f = TAB_LOADERS[name]; if (f) Promise.resolve(f()).catch(() => {}); } document.querySelectorAll('.tabs button').forEach((b) => (b.onclick = () => openTab(b.dataset.tab))); /** * 새로고침 (v1.3.2 수정): 1.3.1 까지는 사이트 목록(개요)만 다시 불러와, 다른 탭을 보고 있으면 아무 변화가 없어 * "작동하지 않는" 것처럼 보였다. 이제 다른 화면으로 이동하지 않고 개요(위쪽 숫자·배지)와 지금 보고 있는 탭의 * 데이터를 모두 다시 불러오며, 끝나면 갱신 시각을 표시한다. 스크롤 위치는 그대로 둔다. */ $('btnReload').onclick = async () => { const b = $('btnReload'); const y = window.scrollY; b.disabled = true; b.textContent = '갱신 중…'; try { await Promise.all([loadOverview(), Promise.resolve(TAB_LOADERS[activeAdminTab] ? TAB_LOADERS[activeAdminTab]() : null)]); $('reloadInfo').textContent = `갱신 ${new Date().toLocaleTimeString(uiLocale())}`; } catch (e) { $('reloadInfo').textContent = '갱신 실패: ' + e.message; } finally { b.disabled = false; b.textContent = '새로고침'; window.scrollTo(0, y); } }; $('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login'; }; $('dBack').onclick = () => openTab('sites'); // ---------- 사이트 관리 ---------- async function loadOverview() { // v1.5.27 (요구 6): 가입 승인 대기 수를 회원 · 청구 탭 배지에 overview = await api('/api/admin/overview'); { const n = overview.pendingApprovals || 0; const b = $('apBadge'); if (b) { b.textContent = n ? String(n) : ''; b.classList.toggle('hidden', !n); } } const kpi = $('kpi'); kpi.innerHTML = ''; const tot = overview.sites.reduce((a, s) => { a.tokens += (s.totals && s.totals.total) || 0; a.cost += (s.totals && s.totals.costUsd) || 0; a.errors += s.errors + s.toolErrors; a.running += s.status === 'running' ? 1 : 0; return a; }, { tokens: 0, cost: 0, errors: 0, running: 0 }); for (const [k, v] of [['회원', overview.users.length], ['사이트', overview.sites.length], ['실행 중', tot.running], ['총 토큰', fmt(tot.tokens)], ['총 비용', usd(tot.cost)], ['오류 건수', tot.errors], ['버전', overview.version || '-']]) { const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `${k}${v}`; kpi.appendChild(d); } if (overview.version) $('ver').textContent = `v${overview.version}`; if (overview.targets) targets = overview.targets; { const fb = overview.feedback || {}; const b = $('fbBadge'); b.textContent = fb.new || ''; b.classList.toggle('hidden', !fb.new); const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `사용자 문의${fb.new || 0} 새 글 / ${fb.total || 0}건`; kpi.appendChild(d); const l = document.createElement('div'); l.className = 'tot'; l.innerHTML = `활성 언어${(overview.languages || 0) + 1}개`; kpi.appendChild(l); } // v1.3.5: "문의 메일 발송 정상(도메인)" 칸 삭제 — 메일은 사이트 단위 메일 시스템·외부 연동 접속 정보에서 관리하며 여러 도메인을 지원한다 { const st = overview.stats || {}; const l = st.local || {}; $('statsCollectLine').textContent = l.at ? `접속 로그 수집: ${l.ok ? `${dt(l.at)} 기준 정상 (${l.lines || 0}줄)` : `${dt(l.at)} 실패 — ${l.detail || ''}`}` : '접속 로그 수집: 아직 실행되지 않음'; } const pendingCount = overview.sites.filter((x) => x.deployState && x.deployState.state === 'pending').length; { const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `배포 대기중${pendingCount}`; kpi.insertBefore(d, kpi.children[2]); } renderSites(); } function renderSites() { const list = overview.sites.filter((x) => siteFilter === 'all' || (x.deployState && x.deployState.state === siteFilter)); const order = { working: 0, pending: 1, deployed: 2, empty: 3 }; list.sort((a, b) => (order[(a.deployState || {}).state] ?? 9) - (order[(b.deployState || {}).state] ?? 9) || ((b.deployState || {}).lastChangeAt || '').localeCompare((a.deployState || {}).lastChangeAt || '')); $('siteCount').textContent = `${list.length}개${siteFilter !== 'all' ? ` (전체 ${overview.sites.length})` : ''}`; const tb = $('sitesTable').querySelector('tbody'); tb.innerHTML = ''; for (const s of list) { const ds = s.deployState || { state: 'empty' }; const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(s.id); const b = document.createElement('span'); b.className = 'badge ' + ds.state; b.textContent = DS[ds.state] || ds.state; b.title = ds.state === 'pending' ? (ds.neverDeployed ? '아직 한 번도 배포되지 않았습니다' : `마지막 배포 ${dt(ds.lastDeployAt)} (${ds.lastDeployTarget || ''}) 이후 변경됨`) : (ds.lastDeployAt ? `마지막 배포 ${dt(ds.lastDeployAt)} → ${ds.lastDeployTarget || ''}` : ''); td(tr, b); td(tr, ds.lastChangeAt ? `${dt(ds.lastChangeAt)} · ${T(CHG[ds.lastChangeKind] || (ds.neverDeployed ? '신규 생성' : ''))}` : '-'); // v1.3.10 조각 번역 td(tr, s.title); td(tr, `${s.ownerName || ''} (${s.ownerId || '-'})`); td(tr, (s.domains || []).join(', ') || (s.source ? '원본: ' + s.source : '-')); { const c = document.createElement('td'); if (s.source) c.textContent = '원본 서버'; else { const sel = document.createElement('select'); sel.style.margin = '0'; sel.style.minWidth = '120px'; sel.title = '이 사이트의 배포 서버'; sel.appendChild(new Option(`기본 서버 (${(targets.targets.find((t) => t.isDefault) || {}).name || '없음'})`, '')); for (const t of targets.targets || []) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id)); sel.value = s.deployTargetId && (targets.targets || []).some((t) => t.id === s.deployTargetId) ? s.deployTargetId : ''; sel.onclick = (e) => e.stopPropagation(); sel.onchange = async (e) => { e.stopPropagation(); try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value || null }) }); s.deployTargetId = sel.value || null; } catch (err) { alert(err.message); } }; c.appendChild(sel); } tr.appendChild(c); } td(tr, MODE[s.mode] || s.mode); td(tr, s.stage || '-'); td(tr, STATUS[s.status] || s.status, 'st-' + s.status); td(tr, s.commands, 'num'); td(tr, s.errors + s.toolErrors, (s.errors + s.toolErrors) ? 'st-error num' : 'num'); td(tr, fmt(s.totals && s.totals.total), 'num'); td(tr, usd(s.totals && s.totals.costUsd), 'num'); const act = document.createElement('td'); act.className = 'act'; const open = document.createElement('a'); open.className = 'btn ghost'; open.href = s.url; open.target = '_blank'; open.rel = 'noopener'; open.textContent = '열기'; open.onclick = (e) => e.stopPropagation(); act.appendChild(open); if (ds.state === 'pending') act.appendChild(mkBtn(ds.neverDeployed ? '첫 배포' : '재배포', 'primary', () => quickDeploy(s))); if (s.status === 'running') act.appendChild(mkBtn('중단', 'ghost warn', () => cancelSite(s.id))); act.appendChild(mkBtn('삭제', 'ghost danger', () => deleteSite(s.id, s.title))); tr.appendChild(act); tb.appendChild(tr); } if (!list.length) { const tr = document.createElement('tr'); td(tr, siteFilter === 'pending' ? '배포 대기중인 사이트가 없습니다' : '사이트가 없습니다').colSpan = 14; tb.appendChild(tr); } } document.querySelectorAll('[data-filter]').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); siteFilter = b.dataset.filter; document.querySelectorAll('[data-filter]').forEach((x) => x.classList.toggle('active', x === b)); renderSites(); })); async function quickDeploy(s) { if (s.source) { if (!confirm(`가져온 기존 사이트입니다. 수정 내용을 원본 서버(${s.source})에 반영할까요?`)) return; return runDeploy(s, { id: 'source', name: '원본 서버 ' + s.source, manageVhost: false, enableSsl: false }, null, null); } if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} } if (!targets.targets.length) return alert('배포 서버가 없습니다. 배포 서버 탭에서 먼저 등록하세요.'); const first = s.deployState && s.deployState.neverDeployed; if (!first) { // 재배포: 정보 입력 없이 지정 서버(없으면 기본)로 바로 진행 // v1.3.17 (B24): 목록의 배포 서버 선택(s.deployTargetId)이 최신 지정 — 예전 배포 기록(s.deploy.targetId)보다 우선 ('' = 기본 서버) const tid = s.deployTargetId || targets.defaultId; // 서버와 같은 규칙: 사이트 지정 서버 → 없으면 기본 서버 const t = targets.targets.find((x) => x.id === tid) || targets.targets.find((x) => x.isDefault); return runDeploy(s, t, null, null); } // 첫 배포: 정보 입력 대화상자 const sel = $('ddTarget'); sel.innerHTML = ''; for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}${t.type === 'ssh' && !t.hasKey ? ' (키 없음)' : ''}`, t.id)); sel.value = (s.deploy && s.deploy.targetId) || targets.defaultId || targets.targets[0].id; $('ddTitle').textContent = '첫 배포 정보 입력'; $('ddSite').textContent = `${s.title} (${s.ownerName || ''} ${s.ownerId || ''})`; $('ddDomains').value = (s.domains || []).join(', '); $('ddSsl').checked = true; $('ddMsg').textContent = ''; $('deployDialog').classList.remove('hidden'); $('ddCancel').onclick = () => $('deployDialog').classList.add('hidden'); $('ddGo').onclick = async () => { const t = targets.targets.find((x) => x.id === sel.value); if (!t) return; $('ddGo').disabled = true; $('ddMsg').textContent = '배포 중…'; try { await runDeploy(s, t, $('ddDomains').value, $('ddSsl').checked); $('deployDialog').classList.add('hidden'); } finally { $('ddGo').disabled = false; } }; } function deployStatus(kind, text, urls) { const box = $('deployStatus'); box.classList.remove('hidden'); box.className = 'deploy-status ' + kind; box.innerHTML = ''; const sp = document.createElement('span'); sp.className = 'ds-icon'; sp.textContent = kind === 'running' ? '' : (kind === 'ok' ? '✓' : '✗'); box.appendChild(sp); const tx = document.createElement('span'); tx.textContent = text; box.appendChild(tx); for (const u of urls || []) { const a = document.createElement('a'); a.href = u.url || u; a.target = '_blank'; a.rel = 'noopener'; a.textContent = u.url || u; a.title = u.note || ''; box.appendChild(a); if (u.note) box.appendChild(document.createTextNode(`(${u.ok === false ? '접속 불가: ' : ''}${u.note}) `)); } if (kind !== 'running') { const x = document.createElement('button'); x.className = 'ghost small-btn'; x.textContent = '닫기'; x.onclick = () => box.classList.add('hidden'); box.appendChild(x); } } async function runDeploy(s, t, domainsText, ssl) { const started = Date.now(); const tick = setInterval(() => deployStatus('running', `배포 진행 중: ${s.title} → ${t.name} · 경과 ${Math.round((Date.now() - started) / 1000)}초`), 1000); deployStatus('running', `배포 진행 중: ${s.title} → ${t.name}`); try { if (domainsText !== null) { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: domainsText }) }); s.domains = r.domains; } // v1.3.17 (B25): 첫 배포 대화상자의 SSL 체크는 이 사이트에만 적용한다(배포 요청에 ssl 로 보냄). 예전에는 서버 공용 설정을 꺼서 다른 사이트에도 영향 if ((s.domains || []).length && (t.manageVhost !== false || t.enableSsl !== false) && t.allowSudo !== true) { if (!confirm(`도메인 연결·SSL 에는 배포 서버 "${t.name}" 에서 sudo 실행이 필요합니다. 허용할까요? (허용하지 않으면 파일만 배포)`)) { /* 파일만 */ } else { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); } } const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: t.id, ...(ssl === true || ssl === false ? { ssl } : {}) }) }); clearInterval(tick); deployStatus('ok', `${r.redeploy ? '재배포' : '배포'} 완료: ${s.title} → ${t.name} (${Math.round((Date.now() - started) / 1000)}초) — ${r.detail} `, (r.access && r.access.items) || (r.domainUrls || []).map((u) => ({ url: u }))); } catch (e) { clearInterval(tick); deployStatus('fail', `배포 실패: ${s.title} → ${t.name} — ${e.message}`); $('ddMsg').textContent = '실패: ' + e.message; throw e; } finally { clearInterval(tick); loadOverview(); } } async function cancelSite(id) { try { await api(`/api/admin/sites/${id}/cancel`, { method: 'POST', body: '{}' }); setTimeout(loadOverview, 1500); } catch (e) { alert(e.message); } } async function deleteSite(id, title) { if (!confirm(`"${title}" 사이트와 파일을 모두 삭제할까요? 되돌릴 수 없습니다.`)) return; try { await api(`/api/admin/sites/${id}`, { method: 'DELETE' }); showTab('sites'); loadOverview(); } catch (e) { alert(e.message); } } function logTo(pre, l) { const t = new Date(l.ts).toLocaleTimeString(uiLocale(), { hour12: false }); const s1 = document.createElement('span'); s1.className = 't'; s1.textContent = `[${t}] `; const s2 = document.createElement('span'); s2.className = l.level || 'info'; s2.textContent = `${l.level === 'debug' ? '[상세] ' : ''}${l.text}`; pre.appendChild(s1); pre.appendChild(s2); pre.appendChild(document.createTextNode('\n')); pre.scrollTop = pre.scrollHeight; } async function openSite(id) { const s = await api(`/api/admin/sites/${id}`); curSite = s; showTab('detail'); activeAdminTab = 'detail'; $('dTitle').textContent = `${s.title} — ${s.ownerName || ''} (${s.ownerId || '-'}) · ${MODE[s.mode] || s.mode}${s.plan ? ' · ' + s.plan.stage : ''}${s.deployState ? ' · ' + (DS[s.deployState.state] || s.deployState.state) : ''} · 등급 ${TIER[s.tier] || s.tier || '-'} (${s.modelRef || '-'})${s.creditWait ? ' · 캐시 충전 대기 중' : ''}`; $('dOpen').href = s.url; $('dReport').href = `/report/${id}`; if (s.stats) { const st = s.stats; const useLog = st.sources.log; const v = (a) => (useLog ? a.visitors : a.beaconVisitors); const pv = (a) => (useLog ? a.pv : a.beaconPv); $('dTitle').textContent += ` · 방문자 오늘 ${v(st.today)} / 이달 ${v(st.month)} / 누적 ${v(st.total)} (페이지뷰 ${pv(st.total)})${useLog ? ` · 송신 ${(st.total.bytesOut / 1048576).toFixed(1)}MB` : ''} · 디스크 ${(st.disk.total / 1048576).toFixed(1)}MB`; } $('dCancel').classList.toggle('hidden', !s.running); $('dCancel').onclick = () => cancelSite(id); $('dDelete').onclick = () => deleteSite(id, s.title); const tb = $('cmdTable').querySelector('tbody'); tb.innerHTML = ''; const pre = $('dLog'); pre.textContent = ''; const showCmd = (c) => { pre.textContent = ''; $('dLogHint').textContent = `명령 #${c.no} ${KIND[c.kind] || c.kind} (${(c.log || []).length}줄)`; for (const l of c.log || []) logTo(pre, l); if (c.error) logTo(pre, { ts: Date.parse(c.finishedAt || c.startedAt) || Date.now(), level: 'warn', text: '오류: ' + c.error }); if (c.prompt) logTo(pre, { ts: Date.parse(c.startedAt) || Date.now(), level: 'debug', text: '프롬프트:\n' + c.prompt.slice(0, 3000) }); }; for (const c of [...s.commands].reverse()) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showCmd(c); td(tr, c.no); td(tr, KIND[c.kind] || c.kind); td(tr, c.model || ''); td(tr, STATUS[c.status] || c.status, 'st-' + c.status); td(tr, dt(c.startedAt)); td(tr, c.usage ? fmt(c.usage.total) + (c.usageEstimated ? '*' : '') : '-', 'num'); td(tr, c.usage && !c.usageEstimated ? usd(c.costUsd) : '-', 'num'); td(tr, c.error || '', 'wrap'); tb.appendChild(tr); } const vt = $('verTable').querySelector('tbody'); vt.innerHTML = ''; for (const v of [...(s.versions || [])].reverse()) { const tr = document.createElement('tr'); td(tr, v.no); td(tr, VER[v.kind] || v.kind); td(tr, dt(v.at)); td(tr, v.message, 'wrap'); vt.appendChild(tr); } $('dPlan').textContent = s.plan ? JSON.stringify({ stage: s.plan.stage, warnings: s.plan.warnings, refsFetched: s.plan.refsFetched, verification: s.plan.verification && s.plan.verification.rules && s.plan.verification.rules.summary, brief: s.plan.brief }, null, 1) : JSON.stringify({ title: s.title, purpose: s.purpose, content: s.content, design: s.design, extra: s.extra }, null, 1); await renderSiteDeploy(s); if (s.commands.length) showCmd(s.commands[s.commands.length - 1]); if (es) es.close(); es = new EventSource(`/api/admin/sites/${id}/events`); es.onmessage = (m) => { const ev = JSON.parse(m.data); if (ev.type === 'log') { $('dLogHint').textContent = '실시간'; logTo(pre, ev); } else if (ev.type === 'done') openSite(id); else if (ev.type === 'status') $('dCancel').classList.toggle('hidden', ev.status !== 'running'); }; } // ---------- 로그 보기 (v1.3.2) ---------- /** * GET /api/admin/logs 로 종류·기간·회사(회원)·사이트·검색어 조건에 맞는 로그를 받는다. * 종류 버튼은 여러 개를 함께 켤 수 있고(기본: 오류·경고·중단), 버튼의 숫자는 다른 조건만 적용한 건수다. * 행을 누르면 해당 사이트 상세(명령 이력·상세 로그)로 간다. */ const LOG_CLS = { error: 'st-error', warn: 'st-warn', cancelled: 'st-idle', deploy: 'st-done', done: 'st-done', debug: '' }; let lgTypes = ['error', 'warn', 'cancelled']; let lgTimer = null; const kstDate = (offsetDays) => new Date(Date.now() + 9 * 3600000 - offsetDays * 86400000).toISOString().slice(0, 10); async function loadErrors() { const range = $('lgRange').value; const custom = range === 'custom'; $('lgFrom').classList.toggle('hidden', !custom); $('lgTo').classList.toggle('hidden', !custom); const p = new URLSearchParams({ types: lgTypes.join(','), owner: $('lgOwner').value, site: $('lgSite').value, q: $('lgQ').value.trim() }); if (custom) { if ($('lgFrom').value) p.set('from', $('lgFrom').value); if ($('lgTo').value) p.set('to', $('lgTo').value); } else if (range !== 'all') p.set('from', kstDate(Number(range) - 1)); const r = await api('/api/admin/logs?' + p.toString()); // 종류 버튼 const kb = $('lgKinds'); kb.innerHTML = ''; for (const [k, label] of Object.entries(r.types)) { const b = document.createElement('button'); b.type = 'button'; b.className = 'lg-kind ' + k + (lgTypes.includes(k) ? ' on' : ''); b.textContent = `${T(label)} ${r.counts[k] || 0}`; b.onclick = () => { lgTypes = lgTypes.includes(k) ? lgTypes.filter((x) => x !== k) : [...lgTypes, k]; if (!lgTypes.length) lgTypes = [k]; loadErrors(); }; kb.appendChild(b); } // 회사·사이트 선택 목록 (선택값 유지) const fill = (sel, items, first, fmt) => { const v = sel.value; sel.innerHTML = ''; sel.appendChild(new Option(first, '')); for (const it of items) sel.appendChild(new Option(fmt(it), it.id)); sel.value = v; }; fill($('lgOwner'), r.owners, '모든 회사(회원)', (o) => `${o.name || o.id} (${o.id})`); fill($('lgSite'), r.sites.filter((x) => !$('lgOwner').value || x.ownerId === $('lgOwner').value), '모든 사이트', (x) => x.title || x.id); $('lgSummary').textContent = `— ${r.total}건${r.total > r.items.length ? ` (최근 ${r.items.length}건 표시)` : ''}`; const tb = $('errTable').querySelector('tbody'); tb.innerHTML = ''; for (const e of r.items) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(e.siteId); td(tr, dt(e.at)); td(tr, r.types[e.type] || e.type, LOG_CLS[e.type] || ''); td(tr, `${e.ownerName || ''} (${e.ownerId || '-'})`, 'wrap'); td(tr, e.siteTitle || '-', 'wrap'); td(tr, e.no ? `#${e.no} ${KIND[e.kind] || e.kind}` : (e.kind === 'unpublish' ? '발행 취소' : '배포')); td(tr, e.text, 'wrap'); tb.appendChild(tr); } if (!r.items.length) { const tr = document.createElement('tr'); td(tr, '조건에 맞는 로그가 없습니다').colSpan = 6; tb.appendChild(tr); } $('lgMore').textContent = r.total > r.items.length ? '조건을 좁히면(기간·회사·사이트·검색어) 나머지 기록도 볼 수 있습니다.' : ''; } for (const id of ['lgRange', 'lgFrom', 'lgTo', 'lgOwner', 'lgSite']) $(id).onchange = () => { if (id === 'lgOwner') $('lgSite').value = ''; loadErrors(); }; $('lgQ').oninput = () => { clearTimeout(lgTimer); lgTimer = setTimeout(loadErrors, 300); }; $('lgReset').onclick = () => { lgTypes = ['error', 'warn', 'cancelled']; $('lgRange').value = '7'; $('lgOwner').value = ''; $('lgSite').value = ''; $('lgQ').value = ''; $('lgFrom').value = ''; $('lgTo').value = ''; loadErrors(); }; // ---------- 배포 서버 ---------- let targets = { defaultId: null, targets: [] }; async function loadTargets() { targets = await api('/api/admin/deploy-targets'); const tb = $('targetTable').querySelector('tbody'); tb.innerHTML = ''; for (const t of targets.targets) { const tr = document.createElement('tr'); td(tr, t.isDefault ? '★' : '', 'star'); td(tr, t.name); td(tr, t.type === 'local' ? '로컬' : 'SSH'); td(tr, t.type === 'local' ? '(이 서버)' : `${t.user}@${t.host}:${t.port}`); td(tr, t.baseDir); td(tr, t.publicBaseUrl || '-'); td(tr, t.type === 'local' ? '-' : (t.hasKey ? '있음' : '없음'), t.type !== 'local' && !t.hasKey ? 'st-error' : ''); td(tr, t.allowSudo === true ? '허용' : '미허용', t.allowSudo === true ? 'st-done' : 'st-cancelled'); td(tr, t.enableSsl !== false ? '자동' : '끔'); td(tr, t.lastTest ? `${t.lastTest.ok ? '성공' : '실패'} · ${dt(t.lastTest.at)}` : '-', t.lastTest ? (t.lastTest.ok ? 'st-done' : 'st-error') : ''); const act = document.createElement('td'); act.className = 'act'; if (t.type !== 'local' || t.allowSudo === true) act.appendChild(mkBtn('오류 서버 점검', 'ghost', async () => { if (t.allowSudo !== true) return alert('sudo 사용 허용이 필요합니다 (수정에서 켜 주세요)'); try { const r = await api(`/api/admin/deploy-targets/${t.id}/default-server`, { method: 'POST', body: '{}' }); alert((r.ok ? '정상: ' : '주의: ') + r.detail); } catch (e) { alert(e.message); } })); act.appendChild(mkBtn('연결 테스트', 'ghost', async () => { try { const r = await api(`/api/admin/deploy-targets/${t.id}/test`, { method: 'POST', body: '{}' }); alert((r.ok ? '성공: ' : '실패: ') + r.detail); loadTargets(); } catch (e) { alert(e.message); } })); // v1.5.13 (요구 3): 생성·조회한 공개키는 별도 창(showKeyDialog)에 한 줄로 보이고 "공개키 복사" 버튼으로 복사한다 if (t.type === 'ssh') act.appendChild(mkBtn(t.hasKey ? '키 재생성' : '키 생성', 'ghost', async () => { if (t.hasKey && !confirm('기존 키를 새 키로 바꿉니다. 배포 서버에 새 공개키를 다시 등록해야 합니다. 계속할까요?')) return; try { const r = await api(`/api/admin/deploy-targets/${t.id}/generate-key`, { method: 'POST', body: '{}' }); showKeyDialog(r.name, r.publicKey, true); loadTargets(); } catch (e) { alert(e.message); } })); if (t.publicKey) act.appendChild(mkBtn('공개키 보기', 'ghost', () => showKeyDialog(t.name, t.publicKey, false))); if (!t.isDefault) act.appendChild(mkBtn('기본으로', 'ghost', async () => { await api(`/api/admin/deploy-targets/${t.id}/default`, { method: 'POST', body: '{}' }); loadTargets(); })); act.appendChild(mkBtn('수정', 'ghost', () => fillTargetForm(t))); act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`"${t.name}" 배포 서버를 삭제할까요? (이미 배포된 사이트 파일은 남습니다)`)) return; await api(`/api/admin/deploy-targets/${t.id}`, { method: 'DELETE' }); loadTargets(); })); tr.appendChild(act); tb.appendChild(tr); } if (!targets.targets.length) { const tr = document.createElement('tr'); td(tr, '등록된 배포 서버가 없습니다. "배포 서버 추가" 를 누르세요.').colSpan = 11; tb.appendChild(tr); } } /** * v1.5.13 (요구 3): 공개키 창. 키 한 줄을 읽기 전용 textarea 에 넣고 "공개키 복사" 로 클립보드에 복사한다. * 클립보드 API 는 https 또는 localhost 에서만 쓸 수 있으므로(사내 http://IP 접속), 안 되면 textarea 선택 + execCommand('copy') 로, 그것도 안 되면 prompt 로 보여 준다. * @param {string} name 배포 서버 이름 @param {string} publicKey 공개키 한 줄 @param {boolean} fresh 방금 생성했는지(안내 문구) */ function showKeyDialog(name, publicKey, fresh) { const key = String(publicKey || '').trim(); $('tKeyTitle').textContent = `${fresh ? '새로 만든 ' : ''}공개키 — ${name}`; $('tKeyNote').innerHTML = (fresh ? '키를 새로 만들었습니다. 이 공개키를 등록해야 배포 서버에 접속할 수 있습니다. ' : '') + '배포 서버의~/.ssh/authorized_keys 에 아래 한 줄을 그대로 추가하세요 (접속 사용자의 홈). 줄바꿈 없이 한 줄입니다.';
$('tKeyText').value = key; $('tKeyMsg').textContent = '';
const dlg = $('tKeyDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', '');
setTimeout(() => { $('tKeyText').focus(); $('tKeyText').select(); }, 50);
}
async function copyKey() {
const ta = $('tKeyText'); const key = ta.value; const done = () => { $('tKeyMsg').textContent = '복사했습니다 — 배포 서버의 ~/.ssh/authorized_keys 에 붙여넣으세요'; $('tKeyCopy').textContent = '복사됨'; setTimeout(() => ($('tKeyCopy').textContent = '공개키 복사'), 1500); };
try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(key); return done(); } } catch (_) {}
try { ta.focus(); ta.select(); ta.setSelectionRange(0, key.length); if (document.execCommand('copy')) return done(); } catch (_) {}
$('tKeyMsg').textContent = '자동 복사가 되지 않는 환경입니다(https 가 아닌 주소). 키가 선택되어 있으니 Ctrl+C 로 복사하세요.';
}
$('tKeyCopy').onclick = copyKey; $('tKeyClose').onclick = () => $('tKeyDialog').close();
/** v1.5.13 (요구 4): 추가·수정 폼을 대화상자로 연다. t 가 있으면 수정, 없으면 빈 폼(추가) */
function fillTargetForm(t) {
$('tFormTitle').textContent = t ? `배포 서버 수정: ${t.name}` : '배포 서버 추가'; $('tId').value = t ? t.id : '';
document.querySelector(`input[name=tType][value=${t ? t.type : 'ssh'}]`).checked = true;
$('tName').value = t ? t.name : ''; $('tHost').value = t ? (t.host || '') : ''; $('tPort').value = t ? (t.port || 22) : ''; $('tUser').value = t ? (t.user || '') : ''; $('tBaseDir').value = t ? t.baseDir : ''; $('tPublicUrl').value = t ? t.publicBaseUrl : ''; $('tNote').value = t ? (t.note || '') : ''; $('tVhost').checked = t ? t.manageVhost !== false : true; $('tSsl').checked = t ? t.enableSsl !== false : true; $('tSudo').checked = t ? t.allowSudo === true : false; $('tKey').value = ''; $('tMsg').textContent = '';
toggleTypeFields();
const dlg = $('tDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', '');
setTimeout(() => $('tName').focus(), 50);
}
function closeTargetForm() { const dlg = $('tDialog'); if (dlg.open) dlg.close(); }
function toggleTypeFields() { const local = document.querySelector('input[name=tType]:checked').value === 'local'; for (const id of ['tHost', 'tPort', 'tUser', 'tKey']) $(id).disabled = local; }
document.querySelectorAll('input[name=tType]').forEach((r) => (r.onchange = toggleTypeFields));
$('tNew').onclick = () => fillTargetForm(null);
$('tCancel').onclick = closeTargetForm;
$('tSave').onclick = async () => {
const id = $('tId').value; const body = { type: document.querySelector('input[name=tType]:checked').value, name: $('tName').value, host: $('tHost').value, port: $('tPort').value || 22, user: $('tUser').value, baseDir: $('tBaseDir').value, publicBaseUrl: $('tPublicUrl').value, note: $('tNote').value, manageVhost: $('tVhost').checked, enableSsl: $('tSsl').checked, allowSudo: $('tSudo').checked, privateKey: $('tKey').value };
try { const r = await api(id ? `/api/admin/deploy-targets/${id}` : '/api/admin/deploy-targets', { method: id ? 'PUT' : 'POST', body: JSON.stringify(body) }); $('tListMsg').textContent = `저장됨 (${r.name})${r.type === 'ssh' && !r.hasKey ? ' — 행의 "키 생성" 으로 키를 만들거나 수정 창에서 개인키를 붙여넣은 뒤 연결 테스트를 하세요' : ''}`; closeTargetForm(); loadTargets(); }
catch (e) { $('tMsg').textContent = '저장 실패: ' + e.message; }
};
async function renderSiteDeploy(s) {
if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} }
const sel = $('dTarget'); sel.innerHTML = ''; sel.appendChild(new Option('(기본 배포 서버)', ''));
for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id));
sel.value = s.deployTargetId || ''; $('dDomains').value = (s.domains || []).join(', ');
$('dDomains').onchange = async () => { try { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: $('dDomains').value }) }); $('dDomains').value = r.domains.join(', '); } catch (e) { alert(e.message); } };
sel.onchange = async () => { try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); } catch (e) { alert(e.message); } };
const box = $('dDeployResult');
const showResult = (cls, title, lines, access) => {
box.className = 'deploy-result ' + cls; box.innerHTML = ''; const h = document.createElement('h4'); h.textContent = title; box.appendChild(h);
for (const l of lines) { const p = document.createElement('div'); p.textContent = l; box.appendChild(p); }
if (access && access.items && access.items.length) { const ul = document.createElement('ul'); for (const it of access.items) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = it.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = it.url; li.appendChild(a); li.appendChild(document.createTextNode(` — ${it.ok ? '✓ ' : '✗ '}${it.note}`)); li.className = it.ok ? 'st-done' : 'st-error'; ul.appendChild(li); } box.appendChild(ul); }
};
async function ensureSudo() {
const tid = sel.value || targets.defaultId; const t = targets.targets.find((x) => x.id === tid); if (!t) return true;
const needs = (t.manageVhost !== false || t.enableSsl !== false) && (s.domains || []).length > 0;
if (!needs || t.allowSudo === true) return true;
if (!confirm(`도메인 연결(nginx 설정)과 SSL 인증서 발급에는 배포 서버 "${t.name}" 에서 sudo 명령 실행이 필요합니다.\n이 서버에서 sudo 사용을 허용할까요? (허용하지 않으면 파일만 배포되고 도메인/SSL 은 설정되지 않습니다)`)) return false;
try { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); targets = await api('/api/admin/deploy-targets'); } catch (e) { alert('설정 저장 실패: ' + e.message); return false; }
return true;
}
$('dDeploy').onclick = async () => {
const ok = await ensureSudo(); if (!ok) { showResult('fail', '배포 취소', ['sudo 사용을 허용하지 않아 배포를 진행하지 않았습니다. 파일만 배포하려면 배포 서버 설정에서 vhost/SSL 자동 설정을 끄세요.']); return; }
showResult('busy', '배포 중…', ['파일 전송 → 도메인 연결 → SSL 발급 → 이전 버전 정리 → 접속 확인 순으로 진행합니다. SSL 발급은 1~2분 걸릴 수 있습니다.']); $('dDeploy').disabled = true;
try {
const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) });
const okAll = r.access && r.access.items.length ? r.access.items.some((i) => i.ok) : true;
showResult(okAll ? 'ok' : 'fail', okAll ? '배포 완료' : '배포는 되었지만 접속되는 주소가 없습니다', [r.detail, r.defaultServer ? `기본 오류 서버: ${r.defaultServer.ok ? '정상' : '주의'} — ${r.defaultServer.detail}` : '', r.ssl ? `SSL: ${r.ssl.detail}` : '', r.access && r.access.items.length ? '접속 가능 여부:' : '접속 주소: 도메인이나 공개 URL 이 없어 확인할 주소가 없습니다. 사이트 도메인을 입력하거나 배포 서버의 공개 URL 을 설정하세요.'].filter(Boolean), r.access);
} catch (e) { showResult('fail', '배포 실패', [e.message]); }
$('dDeploy').disabled = false; const keep = box.cloneNode(true); await openSite(s.id); $('dDeployResult').replaceWith(keep);
};
$('dSsl').onclick = async () => { const ok = await ensureSudo(); if (!ok) return; showResult('busy', 'SSL 인증서 발급 중… (1~2분)', []); $('dSsl').disabled = true; try { const r = await api(`/api/admin/sites/${s.id}/ssl`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? 'SSL 발급 완료' : 'SSL 발급 실패', [r.detail], r.access); } catch (e) { showResult('fail', 'SSL 발급 실패', [e.message]); } $('dSsl').disabled = false; };
// v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 서버에 직전 배포본이 있을 때만 활성
{ const ds = s.deployState || {}; $('dRollback').disabled = !ds.hasPrev; $('dRollback').title = ds.hasPrev ? '배포 서버에 보관된 직전 배포본으로 운영 주소를 되돌립니다' : '배포 서버에 보관된 직전 배포본이 없습니다 (1.6.31 이후 두 번째 배포부터 가능)';
$('dRollback').onclick = async () => { if (!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (빌더의 사이트 내용은 바뀌지 않으며, 다음 배포 때 다시 현재 내용이 올라갑니다)')) return; showResult('busy', '되돌리는 중…', []); try { const r = await api(`/api/admin/recovery/action`, { method: 'POST', body: JSON.stringify({ kind: 'deploy', id: s.id, action: 'rollback' }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? '되돌리기 완료' : '되돌리기 실패', [r.detail || '']); } catch (e) { showResult('fail', '되돌리기 실패', [e.message]); } }; }
$('dAccess').onclick = async () => { showResult('busy', '접속 확인 중…', []); try { const r = await api(`/api/admin/sites/${s.id}/access-check`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.items.some((i) => i.ok) ? 'ok' : 'fail', '접속 확인 결과', [r.items.length ? '' : '확인할 주소가 없습니다 (도메인 또는 공개 URL 필요)'].filter(Boolean), r); } catch (e) { showResult('fail', '확인 실패', [e.message]); } };
const tb = $('depTable').querySelector('tbody'); tb.innerHTML = '';
for (const d of [...((s.deploy && s.deploy.history) || [])].reverse()) { const tr = document.createElement('tr'); td(tr, dt(d.at)); td(tr, d.targetName + ((d.domains || []).length ? ` (${d.domains.join(', ')}${d.vhostOk === false ? ' · vhost 실패' : ''}${d.sslOk === true ? ' · SSL' : (d.sslOk === false ? ' · SSL 실패' : '')})` : '')); td(tr, d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? '성공(접속 불가)' : '성공') : '실패', d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? 'st-cancelled' : 'st-done') : 'st-error'); const u = document.createElement('td'); if (d.url) { const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = d.url; a.style.color = 'var(--accent2)'; u.appendChild(a); } tr.appendChild(u); td(tr, d.detail, 'wrap'); td(tr, d.by || ''); tb.appendChild(tr); }
if (!((s.deploy && s.deploy.history) || []).length) { const tr = document.createElement('tr'); td(tr, '배포 이력이 없습니다').colSpan = 6; tb.appendChild(tr); }
}
// ---------- 회원 · 청구 ----------
let creditsData = { users: [] };
async function loadBilling() {
[billingData, creditsData] = await Promise.all([api('/api/admin/billing'), api('/api/admin/credits')]); const b = billingData;
const mt = $('modelTable').querySelector('tbody'); mt.innerHTML = '';
const prices = Object.fromEntries((settingsData ? settingsData.models : []).map((m) => [m.id, m]));
for (const m of b.models) { const tr = document.createElement('tr'); td(tr, m.model); td(tr, m.calls + (m.estimatedCalls ? ` (추정 ${m.estimatedCalls})` : ''), 'num'); td(tr, fmt(m.input), 'num'); td(tr, fmt(m.output), 'num'); td(tr, fmt(m.cacheCreate), 'num'); td(tr, fmt(m.cacheRead), 'num'); td(tr, fmt(m.tokens), 'num'); td(tr, usd(m.usd), 'num'); const p = prices[m.model]; td(tr, p ? `$${p.input} / $${p.output}` : '-'); mt.appendChild(tr); }
if (!b.models.length) { const tr = document.createElement('tr'); td(tr, '사용 기록이 없습니다').colSpan = 9; mt.appendChild(tr); }
$('fxLine').textContent = `오늘 적용 환율: 1 USD = ${b.fx.today.rate.toLocaleString(uiLocale())}원 (${b.fx.mode === 'manual' ? '관리자 입력' : `${b.fx.today.source}, 중간환율 ${b.fx.today.mid} + 스프레드 ${b.fx.spreadPct}%`}) · 마진 ${b.marginPct}% · 합계: 비용 ${usd(b.totals.usd)} → 정산 ${krw(b.totals.krw)} → 청구 ${krw(b.totals.charge)} · 캐시 잔액 합계 ${krw((creditsData.users || []).reduce((a, x) => a + (x.balance || 0), 0))}${b.rateErrors.length ? ' · 환율 조회 실패: ' + b.rateErrors.join(', ') : ''}`;
const bt = $('billTable').querySelector('tbody'); bt.innerHTML = '';
for (const u of b.users) {
const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showBillUser(u.userId);
const cr = (creditsData.users || []).find((x) => x.id === u.userId) || { balance: 0, used: 0, waiting: 0 };
td(tr, `${u.name || ''} (${u.userId})`); td(tr, u.pendingApproval ? '승인 대기' : (u.role === 'admin' ? '관리자 (한도 없음)' : (u.role ? '일반' : '-')), u.pendingApproval ? 'st-cancelled' : '') /* v1.5.27 승인 대기 표시 */; td(tr, u.sites, 'num'); td(tr, fmt(u.tokens), 'num'); td(tr, usd(u.usd), 'num'); td(tr, krw(cr.used), 'num'); td(tr, krw(cr.balance), 'num ' + (cr.balance <= 0 ? 'neg' : 'pos')); td(tr, cr.waiting ? `${cr.waiting}건` : '-', cr.waiting ? 'neg' : '');
const act = document.createElement('td'); act.className = 'act';
// v1.5.27 (요구 6) 승인 대기 계정: 승인(로그인 가능) 또는 거절(계정 삭제)
if (u.pendingApproval) { act.appendChild(mkBtn('가입 승인', 'primary', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/approve`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); act.appendChild(mkBtn('거절(삭제)', 'ghost danger', async () => { if (!confirm(`${u.name || ''} (${u.userId}) 의 가입 신청을 거절하고 계정을 지울까요?`)) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/reject`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); }
act.appendChild(mkBtn('캐시', 'primary', () => { showBillUser(u.userId); $('chargeAmount').focus(); }));
if (u.role) act.appendChild(mkBtn('비밀번호 재설정', 'ghost', async () => { const pw = prompt(`${u.name || ''} (${u.userId}) 의 새 비밀번호 (${window.pwRuleText || '6자 이상'})`); if (!pw) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/password`, { method: 'POST', body: JSON.stringify({ password: pw }) }); alert('비밀번호를 재설정했습니다'); } catch (e) { alert(e.message); } }));
if (u.role) act.appendChild(mkBtn(u.role === 'admin' ? '일반으로' : '관리자로', 'ghost', async () => { if (me && u.userId === me.id) return alert('자기 자신의 역할은 바꿀 수 없습니다'); try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/role`, { method: 'POST', body: JSON.stringify({ role: u.role === 'admin' ? 'user' : 'admin' }) }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } }));
// v1.4.0 계정 종류: 서브 도메인 전용 / 자체 도메인 — 고르고 "변경" 을 눌러야 적용 (관리자는 항상 자체 도메인)
if (u.role === 'user') {
const box = document.createElement('span'); box.className = 'acct-type'; box.onclick = (e) => e.stopPropagation();
const sel = document.createElement('select'); for (const [v, l] of [['subdomain', '서브 도메인 전용'], ['domain', '자체 도메인']]) { const o = document.createElement('option'); o.value = v; o.textContent = T(l); sel.appendChild(o); } sel.value = u.accountType || 'domain';
box.appendChild(sel); box.appendChild(mkBtn('변경', 'ghost', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/account-type`, { method: 'POST', body: JSON.stringify({ accountType: sel.value }) }); u.accountType = sel.value; alert(TF('계정 종류를 바꿨습니다: {1}', sel.options[sel.selectedIndex].textContent)); } catch (e) { alert(e.message); } }));
act.appendChild(box);
}
tr.appendChild(act); bt.appendChild(tr);
}
if (curBillUser) showBillUser(curBillUser);
loadPrepaid().catch(() => {}); // v1.6.40 선불 충전 상품·주문
}
/* ---------- v1.6.40 (요구 1) 선불 충전 상품 · 주문 ----------
* 상품: 이름 · 결제 금액 · 인센티브(정률 %/정액 원) · 표시 여부 · 순서. 적립 캐시 = 결제 금액 + 인센티브 (서버 lib/prepaid.js 가 계산해 준다).
* 주문: 사용자가 캐시·충전 화면에서 접수한 결제 대기 주문. PG 미연동이라 관리자가 입금(결제) 확인 → "입금 확인 → 적립" 으로 캐시 원장에 더한다. */
let prepaidData = null;
const PP_STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' };
async function loadPrepaid() {
prepaidData = await api('/api/admin/prepaid'); const d = prepaidData;
const tb = $('ppTable').querySelector('tbody'); tb.innerHTML = '';
for (const p of d.packages) {
const tr = document.createElement('tr'); td(tr, p.name + (p.note ? ` — ${p.note}` : ''), 'wrap'); td(tr, krw(p.amount), 'num'); td(tr, p.bonusType === 'percent' ? `${p.bonusValue}% (${krw(p.bonus)})` : krw(p.bonusValue), 'num'); td(tr, krw(p.credit), 'num pos'); td(tr, p.active ? T('표시') : T('숨김'), p.active ? 'pos' : 'neg'); td(tr, String(p.sort || 0), 'num');
const act = document.createElement('td'); act.className = 'act';
act.appendChild(mkBtn('수정', 'ghost', () => { $('ppId').value = p.id; $('ppName').value = p.name; $('ppAmount').value = p.amount; $('ppBonusType').value = p.bonusType; $('ppBonusValue').value = p.bonusValue; $('ppSort').value = p.sort || 0; $('ppActive').checked = p.active !== false; $('ppNote').value = p.note || ''; $('ppFormTitle').textContent = TF('상품 수정 — {1}', p.name); $('ppName').focus(); }));
act.appendChild(mkBtn(p.active ? '숨기기' : '표시', 'ghost', async () => { try { await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ id: p.id, active: !p.active }) }); await loadPrepaid(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('상품 "{1}" 을 지울까요? 주문 기록이 있는 상품은 지우지 않고 숨김으로 바뀝니다.', p.name))) return; try { const r = await api(`/api/admin/prepaid/packages/${p.id}`, { method: 'DELETE' }); $('ppMsg').textContent = r.removed ? T('지웠습니다') : T('주문 기록이 있어 숨김으로 바꿨습니다'); await loadPrepaid(); } catch (e) { alert(e.message); } }));
tr.appendChild(act); tb.appendChild(tr);
}
if (!d.packages.length) { const tr = document.createElement('tr'); td(tr, '아직 상품이 없습니다 — 아래에서 만드세요').colSpan = 7; tb.appendChild(tr); }
const pend = d.orders.filter((o) => o.status === 'pending').length; const badge = $('ppBadge'); badge.textContent = pend ? String(pend) : ''; badge.classList.toggle('hidden', !pend);
drawPrepaidOrders();
}
function drawPrepaidOrders() {
const d = prepaidData; if (!d) return; const f = $('ppOrderFilter').value; const list = d.orders.filter((o) => !f || o.status === f);
const ot = $('ppOrders').querySelector('tbody'); ot.innerHTML = '';
for (const o of list) {
const tr = document.createElement('tr'); td(tr, dt(o.at)); td(tr, `${d.users[o.userId] || ''} (${o.userId})`); td(tr, o.name, 'wrap'); td(tr, krw(o.amount), 'num'); td(tr, krw(o.credit), 'num pos'); td(tr, T(PP_STATUS[o.status] || o.status), o.status === 'paid' ? 'pos' : (o.status === 'pending' ? 'st-cancelled' : ''));
const act = document.createElement('td'); act.className = 'act';
if (o.status === 'pending') {
act.appendChild(mkBtn('입금 확인 → 적립', 'primary', async () => { const memo = prompt(TF('{1} ({2}) 의 입금(결제)을 확인했습니까?\n결제 {3} → 적립 {4}\n메모(입금자명·승인번호 등, 선택):', d.users[o.userId] || '', o.userId, krw(o.amount), krw(o.credit))); if (memo === null) return; try { const r = await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid', memo }) }); $('ppOrderMsg').textContent = TF('적립했습니다 — 잔액 {1}', krw(r.balance)); await loadPrepaid(); loadBilling(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('취소', 'ghost danger', async () => { const memo = prompt(T('취소 사유 (선택):')); if (memo === null) return; try { await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'cancel', memo }) }); await loadPrepaid(); } catch (e) { alert(e.message); } }));
} else if (o.memo) act.appendChild(document.createTextNode(o.memo));
tr.appendChild(act); ot.appendChild(tr);
}
if (!list.length) { const tr = document.createElement('tr'); td(tr, '주문이 없습니다').colSpan = 7; ot.appendChild(tr); }
}
document.addEventListener('DOMContentLoaded', () => {
const f = $('ppOrderFilter'); if (!f) return;
f.onchange = drawPrepaidOrders;
const reset = () => { $('ppId').value = ''; $('ppName').value = ''; $('ppAmount').value = ''; $('ppBonusType').value = 'percent'; $('ppBonusValue').value = ''; $('ppSort').value = ''; $('ppActive').checked = true; $('ppNote').value = ''; $('ppFormTitle').textContent = T('새 상품'); };
$('ppReset').onclick = reset;
$('ppSave').onclick = async () => {
const body = { id: $('ppId').value || undefined, name: $('ppName').value, amount: $('ppAmount').value, bonusType: $('ppBonusType').value, bonusValue: $('ppBonusValue').value, sort: $('ppSort').value, active: $('ppActive').checked, note: $('ppNote').value };
try { const r = await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify(body) }); $('ppMsg').textContent = TF('저장했습니다 — {1}: 결제 {2} → 적립 {3}', r.package.name, krw(r.package.amount), krw(r.package.credit)); reset(); await loadPrepaid(); } catch (e) { $('ppMsg').textContent = e.message; }
};
});
const LEDGER_TYPE = { set: '설정', add: '추가', adjust: '조정', usage: '사용', refund: '환불', migrate: '이관' };
async function showBillUser(uid) {
const u = billingData.users.find((x) => x.userId === uid); if (!u) return; curBillUser = uid;
let cr; try { cr = await api(`/api/admin/credits/${encodeURIComponent(uid)}`); } catch (e) { cr = { balance: 0, used: 0, ledger: [] }; }
$('billDetail').classList.remove('hidden'); $('billDetailTitle').textContent = `${u.name || ''} (${u.userId}) — 캐시 잔액 ${krw(cr.balance)} · 누적 사용 ${krw(cr.used)} · 누적 지급 ${krw(cr.granted || 0)}${u.role === 'admin' ? ' · 관리자(한도 미적용)' : ''}`;
const dtb = $('dayTable').querySelector('tbody'); dtb.innerHTML = '';
for (const d of u.byDay) { const tr = document.createElement('tr'); td(tr, d.day); td(tr, d.commands, 'num'); td(tr, `${d.rate.toLocaleString(uiLocale())}원`, 'num'); td(tr, usd(d.usd), 'num'); td(tr, krw(d.krw), 'num'); td(tr, krw(d.charge), 'num'); dtb.appendChild(tr); }
const ct = $('chargeTable').querySelector('tbody'); ct.innerHTML = '';
for (const e of cr.ledger || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, LEDGER_TYPE[e.type] || e.type, e.type === 'usage' ? 'neg' : 'pos'); td(tr, krw(e.amount), 'num ' + (e.amount < 0 ? 'neg' : 'pos')); td(tr, krw(e.balanceAfter), 'num'); td(tr, e.memo || '', 'wrap'); td(tr, e.by || ''); ct.appendChild(tr); }
if (!(cr.ledger || []).length) { const tr = document.createElement('tr'); td(tr, '기록이 없습니다').colSpan = 6; ct.appendChild(tr); }
const post = async (type) => { const amount = +$('chargeAmount').value; if (!$('chargeAmount').value.trim() || !Number.isFinite(amount)) return alert('금액을 입력하세요'); if (type === 'set' && !confirm(`${u.name || ''} (${uid}) 의 캐시 잔액을 ${krw(amount)} 으로 설정할까요?`)) return; try { await api(`/api/admin/credits/${encodeURIComponent(uid)}`, { method: 'POST', body: JSON.stringify({ type, amount, memo: $('chargeMemo').value }) }); $('chargeAmount').value = ''; $('chargeMemo').value = ''; await loadBilling(); } catch (e) { alert(e.message); } };
$('btnCharge').onclick = () => post('add'); $('btnSetCredit').onclick = () => post('set');
$('billDetail').scrollIntoView({ behavior: 'smooth', block: 'start' }); // v1.1.6: 충전 폼이 원장 위에 있으므로 상세를 열면 바로 보인다
}
// ---------- 사용자 제안·오류 (v1.1.2) ----------
const FB_TYPE = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' }; const FB_STATUS = { new: '새 글', reviewing: '검토 중', done: '처리 완료', rejected: '처리 완료' }; // v1.2.0: 새 글(접수) → 검토 중(AI 검토) → 처리 완료(관리자 답변). 반려 없음
let fbData = null; let fbCur = null;
async function loadFeedback() {
fbData = await api('/api/admin/feedback'); const tb = $('fbTable').querySelector('tbody'); tb.innerHTML = '';
$('fbSummary').textContent = `— ${fbData.counts.total}건 (새 글 ${fbData.counts.new} · 다음 작업 반영 ${fbData.counts.planned || 0}) · AI 분석 비용 합계 ${krw(fbData.counts.costKrw)} (${usd(fbData.counts.costUsd)})`;
const tf = $('fbTypeFilter').value; // v1.3.2 종류 거르기
for (const x of fbData.items.filter((i) => !tf || i.type === tf)) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showFeedback(x.id); td(tr, dt(x.at)); td(tr, FB_TYPE[x.type] || x.type); td(tr, x.title, 'wrap'); td(tr, `${x.userName || ''} (${x.userId})`); td(tr, x.siteTitle || '-'); const a = x.analysis; td(tr, a ? `${a.category || ''} · 중요도 ${a.priority || '-'} · 작업량 ${a.effort || '-'}` : (x.comments.some((c) => c.by === 'ai') ? '분석 실패' : '분석 중…'), 'wrap'); td(tr, x.cost && x.cost.krw ? `${krw(x.cost.krw)} (${usd(x.cost.usd)})` : '-', 'num'); { const b = document.createElement('span'); if (x.planned) { b.className = 'badge planned'; b.textContent = x.collectedAt ? '반영 예정 · 수집됨' : '반영 예정'; b.title = x.planNote || ''; } else b.textContent = '-'; td(tr, b); } td(tr, FB_STATUS[x.status] || x.status, 'st-' + (x.status === 'done' ? 'done' : x.status === 'new' ? 'running' : 'idle')); tb.appendChild(tr); }
if (!fbData.items.length) { const tr = document.createElement('tr'); td(tr, '올라온 글이 없습니다').colSpan = 9; tb.appendChild(tr); }
if (fbCur) showFeedback(fbCur);
}
function showFeedback(id) {
const x = fbData.items.find((i) => i.id === id); if (!x) return; fbCur = id; $('fbDetail').classList.remove('hidden');
$('fbdTitle').textContent = `[${FB_TYPE[x.type] || x.type}] ${x.title} — ${x.userName || ''} (${x.userId}) · ${dt(x.at)}${x.siteTitle ? ' · 사이트: ' + x.siteTitle : ''}${x.page ? ' · 화면: ' + x.page : ''}`;
$('fbdBody').textContent = x.body || '(내용 없음)'; $('fbdStatus').value = x.status; $('fbdReply').value = ''; $('fbdMsg').textContent = '';
$('fbdEnv').textContent = x.env ? Object.entries(x.env).map(([k, v]) => `${k}: ${v}`).join('\n') : '(수집된 환경 정보 없음 — v1.1.6 이전 글)';
$('fbdPlanned').checked = !!x.planned; $('fbdPlanNote').value = x.planNote || '';
$('fbdPlanInfo').textContent = x.planned ? `반영 결정 ${dt(x.plannedAt)} (${x.plannedBy || ''})${x.collectedAt ? ` · 작업 서버 수집 ${dt(x.collectedAt)}` : ' · 아직 수집되지 않음'}` : (x.analysis ? 'AI 분석을 참고해 반영 여부와 반영할 내용을 정하세요.' : 'AI 분석이 끝나면 내용을 참고해 결정하세요.');
const box = $('fbdComments'); box.innerHTML = '';
for (const c of x.comments) { const d = document.createElement('div'); d.className = 'card'; d.style.marginBottom = '6px'; d.innerHTML = `${c.text.replace(/[<>&]/g, (ch) => ({ '<': '<', '>': '>', '&': '&' }[ch]))}`; box.appendChild(d); }
if (!x.comments.length) box.textContent = '아직 댓글이 없습니다 (AI 분석 진행 중일 수 있습니다 — 새로고침).';
// v1.2.0: 답변을 적으면 처리 상태를 "처리 완료"로 미리 맞춰 보여 준다(서버도 관리자 답변 시 자동으로 처리 완료)
$('fbdReply').oninput = () => { if ($('fbdReply').value.trim()) $('fbdStatus').value = 'done'; };
/**
* 저장: 반영 결정 → 상태 → 답변 순으로 서버가 처리한다.
* 1.1.6 까지는 저장 직후 목록을 다시 그리면서(showFeedback) 결과 메시지와 입력값이 지워져 "저장이 안 된 것처럼" 보였다.
* 이제 목록을 다시 불러온 뒤에 결과 메시지를 표시한다.
*/
$('fbdSave').onclick = async () => {
if ($('fbdPlanned').checked && !$('fbdPlanNote').value.trim()) { $('fbdMsg').textContent = '반영할 내용을 구체적으로 적어 주세요'; return; }
const reply = $('fbdReply').value.trim(); $('fbdSave').disabled = true;
try {
const r = await api(`/api/admin/feedback/${id}`, { method: 'PUT', body: JSON.stringify({ status: $('fbdStatus').value, reply, planned: $('fbdPlanned').checked, planNote: $('fbdPlanNote').value }) });
await loadFeedback(); loadOverview();
$('fbdMsg').textContent = reply ? `답변을 저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status} (사용자 화면에 표시됩니다)` : `저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status}`;
} catch (e) { $('fbdMsg').textContent = e.message; }
$('fbdSave').disabled = false;
};
// v1.3.2: AI 1차 분석의 답변 초안을 답변 칸에 넣기 (관리자가 고쳐서 저장)
$('fbdDraft').classList.toggle('hidden', !(x.analysis && x.analysis.replyDraft)); $('fbdDraft').onclick = () => { $('fbdReply').value = x.analysis.replyDraft; $('fbdStatus').value = 'done'; $('fbdReply').focus(); };
$('fbdReanalyze').onclick = async () => { try { await api(`/api/admin/feedback/${id}/analyze`, { method: 'POST', body: '{}' }); $('fbdMsg').textContent = 'AI 분석을 다시 요청했습니다. 잠시 후 새로고침하세요.'; } catch (e) { $('fbdMsg').textContent = e.message; } };
}
// 다음 작업 반영 목록 (v1.1.6): 관리자가 반영으로 결정한 글을 마크다운으로 — 작업 서버가 같은 내용을 수집한다
$('fbBacklogBtn').onclick = async () => { try { const r = await api('/api/admin/feedback/backlog'); $('fbBacklogMd').textContent = r.markdown; $('fbBacklogInfo').textContent = ` — ${r.items.length}건 · 수집 상태: ${r.items.filter((x) => x.collectedAt).length}건 수집됨`; $('fbBacklog').classList.remove('hidden'); } catch (e) { alert(e.message); } };
$('fbBacklogClose').onclick = () => $('fbBacklog').classList.add('hidden');
$('fbBacklogCopy').onclick = async () => { try { await navigator.clipboard.writeText($('fbBacklogMd').textContent); $('fbBacklogCopy').textContent = '복사됨'; setTimeout(() => ($('fbBacklogCopy').textContent = '마크다운 복사'), 1500); } catch (_) {} };
// ---------- 언어 (v1.1.2) ----------
async function loadI18n() {
// v1.3.5: 원본은 항상 지금 서비스 버전의 카탈로그다 (화면을 오래 열어 두면 예전 값이 남으므로 버전 감시가 다시 불러온다)
const d = await api('/api/admin/i18n'); $('i18nSource').textContent = `— 서비스 v${d.serviceVersion || d.source.version} · 원본(한국어) ${d.source.count}개 문구`;
const tb = $('i18nTable').querySelector('tbody'); tb.innerHTML = '';
for (const l of d.languages) { const tr = document.createElement('tr'); td(tr, l.lang); td(tr, l.name); td(tr, `${l.translated} / ${l.total} (${l.coverage}%)`, 'num'); td(tr, `v${l.sourceVersion}${l.outdated ? ` ≠ 서비스 v${l.serviceVersion || d.serviceVersion}` : ' (일치)'}`, l.outdated ? 'st-error' : 'st-done'); td(tr, dt(l.uploadedAt));
// v1.3.5: 실제 표시 상태(서버 계산)와 켜기 스위치를 따로 보여 준다
{ const c = document.createElement('div'); const st = document.createElement('span'); st.className = 'badge ' + ({ shown: 'deployed', outdated: 'empty', off: 'pending', 'off-outdated': 'pending' }[l.state] || 'pending'); st.textContent = l.stateLabel || (l.active ? '표시 중' : '숨김'); st.title = l.stateDetail || ''; c.appendChild(st); const sw = document.createElement('div'); sw.className = 'muted'; sw.style.fontSize = '11px'; sw.textContent = `스위치: ${l.enabled ? '켜짐' : '꺼짐'}`; c.appendChild(sw); td(tr, c); }
const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn(l.enabled ? '끄기' : '켜기', 'ghost', async () => { await api(`/api/admin/i18n/${l.lang}`, { method: 'PUT', body: JSON.stringify({ enabled: !l.enabled }) }); loadI18n(); loadOverview(); })); const ex = document.createElement('a'); ex.className = 'btn ghost'; ex.href = `/api/admin/i18n/export?lang=${encodeURIComponent(l.lang)}`; ex.textContent = '현재 번역 내려받기'; ex.title = '최신 원본 문구에 기존 번역을 채워 넣은 파일 — 바뀐 문구만 번역해 다시 올리면 됩니다'; act.appendChild(ex); act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${l.name}(${l.lang}) 번역을 삭제할까요?`)) return; await api(`/api/admin/i18n/${l.lang}`, { method: 'DELETE' }); loadI18n(); loadOverview(); })); tr.appendChild(act); tb.appendChild(tr); }
if (!d.languages.length) { const tr = document.createElement('tr'); td(tr, '올린 언어가 없습니다. 원본 파일을 내려받아 번역한 뒤 올리세요.').colSpan = 7; tb.appendChild(tr); }
// v1.3.16 서비스 화면 기본 언어 (표시 중인 언어만)
try { const st = await api('/api/admin/settings'); const ui = st.uiLang || { default: 'ko' }; const sel = $('uiDefLang'); sel.innerHTML = ''; sel.appendChild(new Option('한국어 (ko)', 'ko')); for (const l of d.languages.filter((x) => x.state === 'shown')) sel.appendChild(new Option(`${l.name} (${l.lang})`, l.lang)); sel.value = ui.default || 'ko'; if (sel.value !== (ui.default || 'ko')) sel.value = 'ko'; $('uiDefForce').checked = false; $('uiDefMsg').textContent = ui.forcedAt ? TF('마지막 강제 적용: {1}', dt(ui.forcedAt)) : ''; markSaved(); } catch (_) {}
loadSiteMessages();
}
$('uiDefSave').onclick = async () => {
if ($('uiDefForce').checked && !confirm(T('모든 사용자의 화면 언어를 이 언어로 바꿉니다(이후 사용자가 다시 고를 수 있음). 계속할까요?'))) return;
try { const r = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ uiLang: { default: $('uiDefLang').value, force: $('uiDefForce').checked } }) }); $('uiDefMsg').textContent = TF('적용했습니다 — 기본 언어 {1}{2}', r.uiLang.default, $('uiDefForce').checked ? ' · ' + T('모든 사용자에게 강제') : ''); $('uiDefForce').checked = false; markSaved(); } catch (e) { $('uiDefMsg').textContent = e.message; }
};
// ---------- v1.3.16 생성 사이트 시스템 문구 번역 ----------
let smData = null; const smEdits = {};
async function loadSiteMessages() {
const lang = $('smLang').value || 'en';
try { smData = await api(`/api/admin/site-messages?lang=${encodeURIComponent(lang)}`); } catch (_) { return; }
const sel = $('smLang'); if (!sel.options.length) { for (const l of smData.locales) sel.appendChild(new Option(`${l.name} (${l.code})`, l.code)); sel.value = smData.lang; }
$('smSummary').textContent = TF('— {1}개 문구 · 번역 없음 {2}개', smData.items.length, smData.missing); renderSiteMessages();
}
function renderSiteMessages() {
if (!smData) return; const tb = $('smTable').querySelector('tbody'); tb.innerHTML = ''; const q = $('smQ').value.trim().toLowerCase();
for (const it of smData.items) {
if ($('smMissing').checked && it.translation) continue; if (q && !`${it.id} ${it.text} ${it.translation}`.toLowerCase().includes(q)) continue;
const tr = document.createElement('tr'); td(tr, it.id); td(tr, it.group); td(tr, it.text);
const c = document.createElement('td'); const inp = document.createElement('input'); inp.value = smEdits[it.id] !== undefined ? smEdits[it.id] : it.translation; inp.style.margin = '0'; inp.style.width = '100%'; inp.oninput = () => { smEdits[it.id] = inp.value; inp.classList.add('dirty'); }; c.appendChild(inp); tr.appendChild(c);
td(tr, { admin: T('관리자'), ai: 'AI', seed: T('기본 제공'), '': '—' }[it.origin]); tb.appendChild(tr);
}
}
$('smLang').onchange = () => { for (const k of Object.keys(smEdits)) delete smEdits[k]; loadSiteMessages(); };
$('smQ').oninput = renderSiteMessages; $('smMissing').onchange = renderSiteMessages;
$('smSave').onclick = async () => { const n = Object.keys(smEdits).length; if (!n) { $('smMsg').textContent = T('고친 번역이 없습니다'); return; } try { await api(`/api/admin/site-messages/${encodeURIComponent($('smLang').value)}`, { method: 'PUT', body: JSON.stringify({ messages: smEdits }) }); for (const k of Object.keys(smEdits)) delete smEdits[k]; $('smMsg').textContent = TF('{1}개 번역을 저장했습니다 (다음 다국어 맞춤·배포 때 사이트에 반영)', n); loadSiteMessages(); } catch (e) { $('smMsg').textContent = e.message; } };
async function uploadI18n(files) {
if (!files.length) return; const fd = new FormData(); for (const f of files) fd.append('files', f, f.name); $('i18nMsg').textContent = '올리는 중…';
try { const r = await api('/api/admin/i18n/upload', { method: 'POST', body: fd }); const box = $('i18nResults'); box.innerHTML = ''; for (const x of r.results) { const d = document.createElement('div'); d.className = 'muted'; d.style.color = x.ok ? '#6ee7b7' : '#fca5a5'; d.textContent = x.ok ? `✓ ${x.file}: ${x.name}(${x.lang}) ${x.translated}개 번역 저장${x.badPatternCount ? ` — 자리표시({1} 등)가 원문과 다른 ${x.badPatternCount}개 항목은 제외했습니다(예: ${x.badPatterns.slice(0, 2).join(' / ')})` : ''}${x.outdated ? ` — 이 파일은 v${x.sourceVersion} 원본 기준이라 현재 서비스(v${r.version || '?'})에서는 화면에 표시되지 않습니다(바뀐 문구가 섞이는 것 방지). 오른쪽 링크로 "최신 원본 + 지금 올린 번역" 파일을 받아 비어 있는 translation 만 채워 다시 올리세요.` : ''}` : `✗ ${x.error}`; box.appendChild(d);
// v1.3.0: 구버전 기준 번역은 바로 이어서 보충할 수 있게 최신 원본에 기존 번역을 채운 파일 링크를 붙인다
if (x.ok && x.outdated) { const a = document.createElement('a'); a.className = 'btn ghost'; a.style.marginLeft = '6px'; a.href = `/api/admin/i18n/export?lang=${encodeURIComponent(x.lang)}`; a.textContent = `${x.lang} 최신 원본 + 기존 번역 내려받기`; a.setAttribute('download', ''); d.appendChild(a); } } $('i18nMsg').textContent = ''; loadI18n(); loadOverview(); }
catch (e) { $('i18nMsg').textContent = '실패: ' + e.message; }
}
{ const dz = $('i18nDrop'); dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.style.background = '#1e293b'; }); dz.addEventListener('dragleave', () => { dz.style.background = ''; }); dz.addEventListener('drop', (e) => { e.preventDefault(); dz.style.background = ''; uploadI18n([...e.dataTransfer.files]); }); $('i18nFiles').onchange = (e) => { uploadI18n([...e.target.files]); e.target.value = ''; }; }
// ---------- AI 제공자 ----------
let provData = null, provCur = null, provModels = [];
async function loadProviders() {
provData = await api('/api/admin/providers'); const tb = $('provTable').querySelector('tbody'); tb.innerHTML = '';
for (const pv of provData.providers) {
if (pv.id === 'anthropic') continue;
const tr = document.createElement('tr'); td(tr, pv.label); td(tr, pv.enabled ? '활성' : '꺼짐', pv.enabled ? 'st-done' : 'st-idle'); td(tr, pv.id === 'openai' && pv.authMode === 'server-login' ? (pv.codex && pv.codex.loggedIn ? '서버 로그인' : '서버 로그인(미로그인)') : (pv.hasKey ? pv.apiKeyMasked : '없음'), (pv.hasKey || (pv.authMode === 'server-login' && pv.codex && pv.codex.loggedIn)) ? '' : 'st-cancelled'); td(tr, pv.models.length ? `${pv.models.length}개: ${pv.models.slice(0, 4).map((m) => m.id).join(', ')}${pv.models.length > 4 ? ' …' : ''}` : '-', 'wrap'); td(tr, pv.lastTest ? `${pv.lastTest.ok ? '성공' : '실패'} · ${dt(pv.lastTest.at)}` : '-', pv.lastTest ? (pv.lastTest.ok ? 'st-done' : 'st-error') : '');
// v1.6.38 (요구 1): 설정 탭 등급 목록에 들어가는지 — 빠졌으면 이유 (키 없음 등). 1.6.35 까지는 저장 문구가 "반영했습니다" 라고만 해서 원인을 알 수 없었다
td(tr, pv.enabled ? (pv.usable ? `포함 (${pv.usableModels}개)` : `제외 — ${pv.unavailableReason || ''}`) : '-', pv.enabled ? (pv.usable ? 'st-done wrap' : 'st-error wrap') : '');
const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('설정', 'ghost', () => openProv(pv))); tr.appendChild(act); tb.appendChild(tr);
}
}
/** v1.6.38 (요구 1): 제공자 폼 아래 상태 줄 — 설정 탭 등급 목록에 들어가는지, 아니면 왜 빠졌는지 */
function showProvUsable(pv) {
const p = $('pUsable'); if (!p) return;
if (!pv.enabled) { p.textContent = '지금 상태: 꺼짐 — "사용" 을 켜고 저장하면 설정 탭 › 모델 등급 지정 목록에 이 제공자의 모델이 들어갑니다 (API 키와 제공할 모델이 있어야 함)'; p.className = 'muted'; return; }
if (pv.usable) { p.textContent = `지금 상태: 설정 탭 › 모델 등급 지정 목록에 포함됨 (모델 ${pv.usableModels}개)`; p.className = 'muted ok'; }
else { p.textContent = `지금 상태: 설정 탭의 등급 목록에서 제외됨 — ${pv.unavailableReason || ''}`; p.className = 'msg err'; }
}
function openProv(pv) {
provCur = pv; provModels = pv.models.slice(); $('provForm').classList.remove('hidden'); showProvUsable(pv);
$('pAuthRow').classList.toggle('hidden', pv.id !== 'openai'); if (pv.id === 'openai') { document.querySelector(`input[name=pAuth][value=${pv.authMode || 'api-key'}]`).checked = true; const c = pv.codex || {}; $('pCodexInfo').textContent = c.bin ? `— codex ${c.version || ''} ${c.loggedIn ? '로그인됨(' + (c.mode === 'chatgpt' ? 'ChatGPT' : 'API 키') + ')' : '로그인 안 됨: 서버에서 codex login --device-auth 실행'}` : '— 서버에 codex CLI 없음 (npm i -g @openai/codex)'; } document.querySelectorAll('input[name=pAuth]').forEach((r) => (r.onchange = () => { $('pKey').classList.toggle('hidden', r.value === 'server-login' && r.checked); })); $('pKey').classList.toggle('hidden', pv.id === 'openai' && (pv.authMode || 'api-key') === 'server-login'); $('provTitle').textContent = pv.label; $('pEnabled').checked = pv.enabled; $('pKey').value = ''; $('pKey').placeholder = pv.hasKey ? `저장됨: ${pv.apiKeyMasked} (바꾸려면 새 키 입력) · ${pv.keyHint}` : `API 키 (${pv.keyHint})`; $('pBase').classList.toggle('hidden', pv.id !== 'custom'); $('pBase').value = pv.baseUrl || ''; $('pMsg').textContent = ''; renderProvModels();
}
function renderProvModels(fetched) {
const tb = $('pModels').querySelector('tbody'); tb.innerHTML = '';
const list = fetched || provModels; const selected = new Set(provModels.map((m) => m.id));
for (const m of list) {
const tr = document.createElement('tr'); const c = document.createElement('td'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.width = 'auto'; cb.checked = selected.has(m.id); cb.onchange = () => { if (cb.checked) { if (!provModels.some((x) => x.id === m.id)) provModels.push({ id: m.id, label: m.label || m.id, input: m.input || 0, output: m.output || 0 }); } else provModels = provModels.filter((x) => x.id !== m.id); }; c.appendChild(cb); tr.appendChild(c);
td(tr, (m.label && m.label !== m.id ? `${m.id} — ${m.label}` : m.id) + (m.description ? ` · ${m.description}` : ''), 'wrap'); // v1.4.14 서버 로그인 목록의 설명·종료 예정 표시
for (const k of ['input', 'output']) { const cell = document.createElement('td'); const inp = document.createElement('input'); inp.type = 'number'; inp.step = '0.01'; inp.style.width = '90px'; inp.style.margin = '0'; inp.value = m[k] || 0; inp.onchange = () => { m[k] = +inp.value; const sel = provModels.find((x) => x.id === m.id); if (sel) sel[k] = +inp.value; }; cell.appendChild(inp); tr.appendChild(cell); }
tb.appendChild(tr);
}
if (!list.length) { const tr = document.createElement('tr'); td(tr, '모델 조회를 눌러 목록을 불러오세요').colSpan = 4; tb.appendChild(tr); }
}
$('pFetch').onclick = async () => { $('pMsg').textContent = '조회 중…'; try { if (provCur.id === 'openai') { const am = document.querySelector('input[name=pAuth]:checked').value; if (am !== provCur.authMode) { await api(`/api/admin/providers/openai`, { method: 'PUT', body: JSON.stringify({ authMode: am }) }); provCur.authMode = am; } } const serverLogin = provCur.id === 'openai' && provCur.authMode === 'server-login'; const r = await api(`/api/admin/providers/${provCur.id}/test`, { method: 'POST', body: JSON.stringify({ apiKey: serverLogin ? '' : ($('pKey').value.trim() || '••••'), baseUrl: $('pBase').value.trim() }) }); if (!r.ok) { $('pMsg').textContent = '실패: ' + r.error; return; } $('pMsg').textContent = `모델 ${r.models.length}개 조회됨 — 제공할 모델을 체크하세요`; renderProvModels(r.models.map((m) => { const sel = provModels.find((x) => x.id === m.id); return sel ? { ...m, input: sel.input, output: sel.output } : m; })); } catch (e) { $('pMsg').textContent = '실패: ' + e.message; } };
$('pSave').onclick = async () => { try { const body = { enabled: $('pEnabled').checked, models: provModels, baseUrl: $('pBase').value.trim() }; if (provCur.id === 'openai') body.authMode = document.querySelector('input[name=pAuth]:checked').value; if ($('pKey').value.trim()) body.apiKey = $('pKey').value.trim(); const r = await api(`/api/admin/providers/${provCur.id}`, { method: 'PUT', body: JSON.stringify(body) }); provCur = { ...provCur, ...r }; showProvUsable(r);
// v1.6.38 (요구 1): 저장은 됐지만 등급 목록에 못 들어가면(키 없음·모델 없음·서버 로그인 안 됨) 그 이유를 바로 말한다 — "추가했는데 설정 탭에 반영이 안 됨" 의 원인
$('pMsg').textContent = r.enabled && !r.usable ? `저장했지만 설정 탭의 등급 목록에는 나오지 않습니다 — ${r.unavailableReason || ''}` : `저장됨 (${r.models.length}개 모델 제공${r.enabled ? '' : ', 비활성'}) — 설정 탭의 "모델 등급 지정"·"내부 호출 모델" 목록에 반영했습니다`; $('pMsg').className = r.enabled && !r.usable ? 'msg err' : 'muted';
await loadSettings(); await loadProviders(); /* v1.4.14 (요구 6): 등급 지정 목록을 바로 다시 그린다 · v1.6.38: 제공자 표의 "등급 목록" 열도 */ } catch (e) { $('pMsg').textContent = '저장 실패: ' + e.message; } };
$('pClose').onclick = () => $('provForm').classList.add('hidden');
// ---------- 설정 ----------
// ---------- 외부 연동 접속 정보 (v1.3.1) ----------
/**
* 다른 서비스에 접속하는 주소·설정·키(API Key/Token)를 암호화 보관소(data/credentials.json)에 넣고 관리한다.
* 키 원문은 서버가 돌려주지 않으므로 화면에는 hint(앞뒤 몇 글자)만 보인다. 키 칸을 비워 저장하면 기존 키를 유지한다.
* API: GET/POST /api/admin/credentials, PUT/DELETE /api/admin/credentials/미리보기를 불러오지 못했습니다
'; } } const box = $('thFrames'); box.innerHTML = ''; box.className = 'th-frames ' + (thView === 'all' ? 'all' : 'one'); const views = thView === 'all' ? Object.keys(TH_VIEWS) : [thView]; for (const v of views) { const conf = TH_VIEWS[v]; const wrap = document.createElement('div'); wrap.className = 'th-frame-wrap'; wrap.dataset.view = v; const cap = document.createElement('div'); cap.className = 'muted'; cap.textContent = `${conf.label} — ${conf.w}px`; wrap.appendChild(cap); const vp = document.createElement('div'); vp.className = 'th-vp'; const f = document.createElement('iframe'); f.width = conf.w; f.height = conf.h; f.srcdoc = thPages[conf.page]; f.setAttribute('sandbox', 'allow-same-origin'); f.onload = () => { try { fillSample(f.contentDocument, conf.page); applyCss(f); } catch (_) {} }; vp.appendChild(f); wrap.appendChild(vp); box.appendChild(wrap); } fitFrames(); } /** 각 iframe 을 칸 폭에 맞게 축소 (실제 화면 폭으로 그린 뒤 transform: scale) */ function fitFrames() { document.querySelectorAll('#thFrames .th-frame-wrap').forEach((w) => { const f = w.querySelector('iframe'); const vp = w.querySelector('.th-vp'); const avail = vp.clientWidth || w.clientWidth; const sc = Math.min(1, avail / f.width); f.style.transform = `scale(${sc})`; vp.style.height = Math.round(f.height * sc) + 'px'; }); } window.addEventListener('resize', () => { if (!$('tab-theme').classList.contains('hidden')) fitFrames(); }); function applyCss(f) { try { const st = f.contentDocument.getElementById('wmPreviewTheme'); if (st) st.textContent = thCss; } catch (_) {} } document.querySelectorAll('#thViews button').forEach((b) => (b.onclick = () => { thView = b.dataset.view; document.querySelectorAll('#thViews button').forEach((x) => x.classList.toggle('active', x === b)); buildPreview(); })); /** 편집 값이 바뀌면(0.15초 모아서) 서버에서 CSS 를 받아 모든 미리보기 iframe 에 넣는다 */ function previewTheme() { const l = thCur.layout; $('thLeftWidthV').textContent = l.leftWidth + 'px'; $('thFontSizeV').textContent = l.fontSize + 'px'; $('thRadiusV').textContent = l.radius + 'px'; clearTimeout(thTimer); thTimer = setTimeout(async () => { try { thCss = (await api('/api/admin/settings/theme-preview', { method: 'POST', body: JSON.stringify({ theme: thCur }) })).css; document.querySelectorAll('#thFrames iframe').forEach(applyCss); } catch (_) {} }, 150); } $('thPreset').onchange = () => { const id = $('thPreset').value; if (id === 'custom') { thCur.preset = 'custom'; return; } const p = thInfo.presets[id]; thCur = { preset: id, colors: { ...thInfo.defaults.colors, ...(p.colors || {}) }, layout: { ...thCur.layout, ...(p.layout || {}) } }; renderTheme(); }; for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'fontSize', 1], ['thRadius', 'radius', 1], ['thDensity', 'density', 0], ['thPanelSide', 'panelSide', 0], ['thHeaderStyle', 'headerStyle', 0]]) $(id).oninput = $(id).onchange = () => { thCur.layout[key] = num ? Number($(id).value) : $(id).value; previewTheme(); }; $('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } }; $('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } }; async function loadSettings() { settingsData = await api('/api/admin/settings'); const s = settingsData; loadVault(); $('ver').textContent = s.version ? `v${s.version}` : ''; renderTiers(s); $('sSignupCredit').value = s.signupCredit || 0; $('sMaxEdits').value = s.maxEdits == null ? 10 : s.maxEdits; // v1.5.27 (요구 6) 회원가입 규칙: ON/OFF 는 설정 파일(service.allowSignup, /api/admin/config), 승인 방식은 settings.signupApproval $('suApproval').value = s.signupApproval || 'immediate'; api('/api/admin/config').then((c) => { const k = ((c.config && c.config.groups) || []).flatMap((g) => g.keys || []).find((x) => x.key === 'service.allowSignup'); if (k) $('suEnabled').checked = k.value !== false && k.value !== 'false' && k.value !== '0'; markSaved(); }).catch(() => {}); fillDomains(s.domainsConfig); // v1.4.0 도메인 서비스 설정 fillWelcome(s); // v1.4.5 사용자 첫 화면 페이지 cashUnit = s.cashUnit || '원'; $('sCashUnit').value = cashUnit; document.querySelectorAll('.cash-unit').forEach((e) => { e.textContent = cashUnit; }); $('chargeAmount').placeholder = TF('금액({1})', cashUnit); // v1.4.1 캐시 표시 단위 $('sBacklogKey').value = ''; $('sBacklogKey').placeholder = s.backlogKeyHint ? `저장됨: ${s.backlogKeyHint} (바꾸려면 새 키 입력)` : '수집 키 (비워 두면 비활성)'; $('sServiceKey').value = ''; $('sServiceKey').placeholder = s.serviceApiKeyHint ? `저장됨: ${s.serviceApiKeyHint} (바꾸려면 새 키 입력)` : '서비스 키 (비워 두면 외부 연동 비활성)'; renderClaudeModels(s); // v1.5.16 AI 연결 탭: 사용할 Claude 모델 체크 목록 document.querySelector(`input[name=authMode][value=${s.auth.mode}]`).checked = true; $('sOauth').value = ''; $('sOauth').placeholder = s.auth.oauthTokenHint ? `저장됨: ${s.auth.oauthTokenHint} (바꾸려면 새 토큰 입력)` : 'OAuth 토큰 붙여넣기'; $('sApiKey').value = ''; $('sApiKey').placeholder = s.auth.apiKeyHint ? `저장됨: ${s.auth.apiKeyHint} (바꾸려면 새 키 입력)` : 'sk-ant-api03-…'; $('serverLoginInfo').textContent = (s.serverLogin.email ? `— 서버에 로그인된 계정: ${s.serverLogin.email}${s.serverLogin.billing ? ` (${s.serverLogin.billing})` : ''}` : '— 서버에 로그인된 계정 없음') + (s.claude ? (s.claude.bin ? ` · claude ${s.claude.version || ''} @ ${s.claude.bin}` : ` · claude 실행 파일 없음 (탐색: ${s.claude.searched.join(', ')})`) : ''); document.querySelector(`input[name=fxMode][value=${s.fx.mode}]`).checked = true; $('sSpread').value = s.fx.spreadPct; $('sManualRate').value = s.fx.manualRate || ''; $('sMargin').value = s.marginPct; $('sLeEmail').value = s.letsEncryptEmail || ''; // v1.3.17 비밀번호 규칙 (없으면 기본: 6자 이상) { const pp = { minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) }; $('ppMin').value = pp.minLength; $('ppMax').value = pp.maxLength; $('ppLetter').checked = !!pp.requireLetter; $('ppMixed').checked = !!pp.requireMixedCase; $('ppDigit').checked = !!pp.requireDigit; $('ppSymbol').checked = !!pp.requireSymbol; $('ppNoId').checked = !!pp.forbidId; $('ppMsg').textContent = TF('지금 규칙: {1}', ppDescribe(pp)); } markSaved(); // v1.3.16 지금 값을 "저장된 값" 으로 기억 (고치면 .dirty 표시) refreshFxNow(false); loadProviders().catch(() => {}); loadMail().catch(() => {}); loadGit().catch(() => {}); loadSsl().catch(() => {}); // v1.4.14 와일드카드 인증서 상태 } async function loadGit() { $('gStatus').textContent = '확인 중…'; $('gStatus').style.color = ''; const g = await api('/api/admin/settings/git'); $('gStatus').textContent = (g.enabled ? `원격: ${g.remote} · ` : '') + g.detail + (g.ms ? ` (${g.ms}ms)` : ''); $('gStatus').style.color = g.ok ? '#6ee7b7' : (g.enabled ? '#fca5a5' : ''); if (g.publicKey) { $('gPubKey').value = `이 서버의 공개키 (${g.keyFile || ''}):\n${g.publicKey}`; $('gPubKey').classList.remove('hidden'); } else $('gPubKey').classList.add('hidden'); } $('gTest').onclick = () => loadGit().catch((e) => { $('gStatus').textContent = '확인 실패: ' + e.message; }); /** * 모델 등급 지정 (기본·중급·고급 → 실제 모델). * v1.4.14 (요구 6): 사용 가능한 모델 전체를 제공자별 묶음(optgroup)으로 보이고, 켜 두었지만 목록에 못 들어간 제공자는 이유를 아래에 적는다. * 1.4.5 까지는 AI 제공자에서 모델을 추가·저장해도 이 목록이 다시 그려지지 않아(설정 화면을 새로 열어야 보임) "추가한 모델이 안 보인다" 는 문제가 있었다 * → pSave 가 저장 뒤 loadSettings() 를 다시 부른다. */ function renderTiers(s) { const box = $('tierRows'); box.innerHTML = ''; const models = s.availableModels || []; const groups = []; for (const m of models) { const key = m.providerLabel || m.provider || ''; let g = groups.find((x) => x.key === key); if (!g) { g = { key, items: [] }; groups.push(g); } g.items.push(m); } /** 사용 가능한 모델 전체를 제공자별 묶음으로 채운 select. cur 가 목록에 없으면(제공자를 껐거나 모델을 뺌) "현재 사용 불가" 항목으로 보인다 — v1.5.16: 내부 호출 모델도 같은 목록을 쓴다 */ const fillModelSelect = (sel, cur) => { sel.innerHTML = ''; for (const g of groups) { const og = document.createElement('optgroup'); og.label = g.key; for (const m of g.items) og.appendChild(new Option(`${m.label} (${m.ref}) — 입력 ${m.input} / 출력 ${m.output} per 1M`, m.ref)); sel.appendChild(og); } if (cur && ![...sel.options].some((o) => o.value === cur)) sel.appendChild(new Option(`${cur} (현재 사용 불가 — AI 연결 탭에서 제공자·모델 확인)`, cur)); sel.value = cur; }; for (const t of s.tierCatalog || []) { const row = document.createElement('div'); row.className = 'row'; row.style.marginBottom = '6px'; const lab = document.createElement('span'); lab.style.minWidth = '110px'; lab.innerHTML = `${t.label} ${t.desc}`; row.appendChild(lab); const sel = document.createElement('select'); sel.dataset.tier = t.id; sel.style.margin = '0'; sel.style.flex = '1'; fillModelSelect(sel, (s.tiers || {})[t.id] || ''); row.appendChild(sel); box.appendChild(row); } // v1.5.16 (요구 2): 내부 호출 모델 — 등급 지정과 같은 목록(AI 연결 탭에서 켠 모델)에서 고른다. 저장은 "모델 변경" 버튼(btnSaveInternal) fillModelSelect($('sInternalRef'), s.internalRef || ''); const note = document.createElement('p'); note.className = 'muted'; note.style.margin = '2px 0 6px'; const miss = s.unavailableProviders || []; note.textContent = TF('고를 수 있는 모델 {1}개 ({2})', models.length, groups.map((g) => `${g.key} ${g.items.length}`).join(', ')); box.appendChild(note); // v1.6.38 (요구 1): 켜 두었지만 목록에 못 들어간 제공자는 빨간 줄로 따로 — 회색 한 줄에 묻혀 "추가한 모델이 안 보인다" 로 이어지던 문제 if (miss.length) { const warn = document.createElement('p'); warn.className = 'msg err'; warn.style.margin = '0 0 6px'; warn.textContent = TF('목록에 들어가지 못한 AI 제공자: {1} — AI 연결 탭에서 고치면 바로 이 목록에 나옵니다', miss.map((x) => `${x.label}(${x.reason})`).join(' / ')); box.appendChild(warn); } const dsel = $('sDefaultTier'); dsel.innerHTML = ''; for (const t of s.tierCatalog || []) dsel.appendChild(new Option(t.label, t.id)); dsel.value = s.defaultTier || 'standard'; } $('btnStatsCollect').onclick = async () => { $('statsCollectLine').textContent = '접속 로그 수집 중…'; try { const r = await api('/api/admin/stats/collect', { method: 'POST', body: '{}' }); const l = r.local || {}; const rem = (r.remote && r.remote.items) || []; $('statsCollectLine').textContent = `접속 로그 수집: 로컬 ${l.ok ? `정상 (${l.lines || 0}줄)` : '실패 — ' + (l.detail || '')}${rem.length ? ' · 원격 ' + rem.map((x) => `${x.target}/${x.site}: ${x.ok ? x.lines + '줄' : '실패(' + x.detail + ')'}`).join(', ') : ''}`; loadOverview(); } catch (e) { $('statsCollectLine').textContent = '수집 실패: ' + e.message; } }; $('btnSaveSignup').onclick = async () => { // v1.5.27: 두 저장소를 차례로 — 설정 파일(가입 허용) → 관리자 설정(승인 방식) $('suMsg').textContent = T('저장 중…'); try { await api('/api/admin/config', { method: 'PUT', body: JSON.stringify({ values: { 'service.allowSignup': $('suEnabled').checked } }) }); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupApproval: $('suApproval').value }) }); await loadSettings(); $('suMsg').textContent = TF('저장했습니다 — 회원가입 {1}, {2}', $('suEnabled').checked ? T('허용') : T('닫힘'), $('suApproval').value === 'approval' ? T('관리자 승인 후 이용') : T('가입 즉시 이용')); $('suMsg').className = 'muted ok'; } catch (e) { $('suMsg').textContent = T('실패') + ': ' + e.message; $('suMsg').className = 'msg err'; } }; $('btnSaveMaxEdits').onclick = () => saveSetting({ maxEdits: $('sMaxEdits').value }, 'maxEditsMsg', (s) => (s.maxEdits ? TF('저장했습니다 — {1}회', s.maxEdits) : T('저장했습니다 — 무제한'))); /** v1.4.5 사용자 첫 화면 페이지 선택 상자 (관리자가 만든 사이트) */ function fillWelcome(s) { const sel = $('sWelcome'); const list = s.welcomeCandidates || []; sel.innerHTML = ''; sel.appendChild(new Option(T('기본 안내 (페이지 없음)'), '')); for (const c of list) { const o = new Option(`${c.title} — ${c.published ? T('배포됨') : (c.hasIndex ? T('미배포') : T('첫 페이지 없음'))}`, c.id); o.disabled = !c.published && !c.hasIndex; sel.appendChild(o); } sel.value = list.some((c) => c.id === s.welcomeSiteId) ? s.welcomeSiteId : ''; const cur = list.find((c) => c.id === sel.value); $('welcomeOpen').href = cur ? cur.url : '#'; $('welcomeOpen').classList.toggle('hidden', !cur); if (!list.length) $('welcomeMsg').textContent = T('관리자 계정이 만든 사이트가 없습니다. /builder 에서 먼저 만드세요'); } $('btnSaveWelcome').onclick = () => saveSetting({ welcomeSiteId: $('sWelcome').value }, 'welcomeMsg', (s) => { const c = (s.welcomeCandidates || []).find((x) => x.id === s.welcomeSiteId); return c ? TF('저장했습니다 — {1}', c.title) : T('저장했습니다 — 기본 안내'); }); $('btnSaveCashUnit').onclick = () => saveSetting({ cashUnit: $('sCashUnit').value }, 'cashUnitMsg', () => TF('저장했습니다 — 예: {1}', krw(12000))); // v1.4.1 저장 후 loadSettings 가 cashUnit 을 바꾼 뒤 예시를 보인다 $('btnSaveSignupCredit').onclick = () => saveSetting({ signupCredit: $('sSignupCredit').value }, 'signupCreditMsg', (s) => TF('저장했습니다 — {1}', krw(s.signupCredit || 0))); $('btnSaveTiers').onclick = async () => { const tiers = {}; document.querySelectorAll('#tierRows select').forEach((sel) => { tiers[sel.dataset.tier] = sel.value; }); try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ tiers, defaultTier: $('sDefaultTier').value }) }); $('tierMsg').textContent = '저장되었습니다 — 다음 작업부터 적용'; loadOverview(); } catch (e) { $('tierMsg').textContent = '실패: ' + e.message; } }; $('btnGenBacklogKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sBacklogKey').value = 'wmb_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); }; $('btnSaveBacklogKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ backlogKey: $('sBacklogKey').value.trim() }) }); $('backlogKeyMsg').textContent = $('sBacklogKey').value.trim() ? '저장되었습니다. 이 키를 작업 서버의 수집 설정에 등록하세요(다시 표시되지 않음).' : '수집을 껐습니다.'; await loadSettings(); } catch (e) { $('backlogKeyMsg').textContent = '실패: ' + e.message; } }; $('btnGenServiceKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sServiceKey').value = 'wmk_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); }; $('btnSaveServiceKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ serviceApiKey: $('sServiceKey').value.trim() }) }); $('serviceKeyMsg').textContent = $('sServiceKey').value.trim() ? '저장되었습니다. 이 키는 다시 표시되지 않으니 외부 서비스에 지금 등록하세요.' : '외부 연동을 비활성화했습니다.'; await loadSettings(); } catch (e) { $('serviceKeyMsg').textContent = '실패: ' + e.message; } }; async function loadMail() { const m = await api('/api/admin/settings/mail'); $('mKeyFile').value = m.keyFile || ''; $('mApiKey').value = ''; $('mApiKey').placeholder = m.source === 'vault' ? '암호화 보관소의 키 사용 중 (바꾸려면 새 키 입력)' : m.source === 'settings' ? '저장된 키 사용 중 (바꾸려면 새 키 입력)' : '또는 Resend API 키 직접 입력 (re_…)'; $('mDomain').value = m.defaultDomain || ''; $('mFromName').value = m.fromName || ''; $('mFormBase').value = m.formBase || ''; $('mStatus').textContent = m.configured ? `키 확인됨 (${m.source === 'vault' ? '암호화 보관소' : m.source === 'file' ? '평문 키 파일 ' + (m.keyName || '') : '직접 입력'}) · Resend 검증 도메인: ${m.verifiedDomains.length ? m.verifiedDomains.join(', ') : '없음/조회 실패'}${m.error ? ' · ' + m.error : ''}` : `미설정: ${m.error || ''}`; } $('mSave').onclick = async () => { try { const body = { mail: { keyFile: $('mKeyFile').value, defaultDomain: $('mDomain').value, fromName: $('mFromName').value, formBase: $('mFormBase').value } }; if ($('mApiKey').value.trim()) body.mail.apiKey = $('mApiKey').value.trim(); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); await loadMail(); alert('저장되었습니다'); } catch (e) { alert(e.message); } }; $('mTest').onclick = async () => { const to = $('mTestTo').value.trim(); if (!to) return alert('테스트 수신 주소를 입력하세요'); $('mTestMsg').textContent = '발송 중…'; try { const r = await api('/api/admin/settings/mail-test', { method: 'POST', body: JSON.stringify({ to, domain: $('mDomain').value }) }); $('mTestMsg').textContent = r.ok ? `발송 성공 (발신 ${r.from})` : '실패: ' + r.error; } catch (e) { $('mTestMsg').textContent = '실패: ' + e.message; } }; async function refreshFxNow(force) { try { const r = force ? await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' }) : (billingData ? billingData.fx.today : await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' })); $('fxNow').textContent = `현재 적용: 1 USD = ${r.rate.toLocaleString(uiLocale())}원 (${r.source}${r.spreadPct ? `, 스프레드 ${r.spreadPct}%` : ''})`; } catch (e) { $('fxNow').textContent = '환율 조회 실패: ' + e.message; } } $('btnSaveAuth').onclick = async () => { const mode = document.querySelector('input[name=authMode]:checked').value; const body = { auth: { mode } }; if ($('sOauth').value.trim()) body.auth.oauthToken = $('sOauth').value.trim(); if ($('sApiKey').value.trim()) body.auth.apiKey = $('sApiKey').value.trim(); if (mode === 'oauth-token' && !body.auth.oauthToken && !settingsData.auth.oauthToken) return alert('OAuth 토큰을 입력하세요'); if (mode === 'api-key' && !body.auth.apiKey && !settingsData.auth.apiKey) return alert('API 키를 입력하세요'); try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); $('connResult').textContent = '저장됨. 연결 테스트로 확인하세요. (인증이 되면 사용할 Claude 모델이 설정 탭의 등급 목록에 나옵니다)'; loadSettings(); } catch (e) { alert(e.message); } }; $('btnTestConn').onclick = async () => { $('connResult').textContent = '테스트 중… (10~30초)'; $('btnTestConn').disabled = true; try { const r = await api('/api/admin/settings/test-connection', { method: 'POST', body: '{}' }); $('connResult').textContent = r.ok ? `연결 성공 — 모델 ${r.model}, 응답 "${r.reply}", 비용 ${usd(r.costUsd)}${r.claude && r.claude.bin ? ` (${r.claude.bin})` : ''}` : `연결 실패 — ${r.error || r.subtype}`; } catch (e) { $('connResult').textContent = '연결 실패 — ' + e.message; } $('btnTestConn').disabled = false; }; $('aPwSave').onclick = async () => { try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ current: $('aPwCur').value, next: $('aPwNew').value }) }); $('aPwMsg').textContent = '변경되었습니다'; $('aPwCur').value = $('aPwNew').value = ''; } catch (e) { $('aPwMsg').textContent = e.message; } }; /** * v1.3.16 설정 값 하나(또는 한 묶음)를 저장하고, 서버에 다시 물어 실제로 저장된 값을 화면에 보인다. * 저장 버튼이 값마다 따로 있어 "고쳤는데 저장 안 됨" 을 막고, 결과 메시지에 저장된 값을 그대로 적어 확인할 수 있게 한다. * @param {object} patch PUT /api/admin/settings 본문 * @param {string} msgId 결과를 쓸 요소 id * @param {(s:object)=>string} done 저장 후 서버 설정으로 만든 확인 문구 */ async function saveSetting(patch, msgId, done) { $(msgId).textContent = T('저장 중…'); try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(patch) }); await loadSettings(); $(msgId).textContent = done(settingsData); $(msgId).className = 'muted ok'; } catch (e) { $(msgId).textContent = T('실패') + ': ' + e.message; $(msgId).className = 'msg err'; } } $('btnSaveFx').onclick = async () => { await saveSetting({ fx: { mode: document.querySelector('input[name=fxMode]:checked').value, spreadPct: $('sSpread').value, manualRate: $('sManualRate').value } }, 'fxMsg', (s) => TF('저장했습니다 — {1}', s.fx.mode === 'manual' ? TF('직접 입력 1 USD = {1}원', s.fx.manualRate) : TF('자동 조회, 스프레드 {1}%', s.fx.spreadPct))); billingData = null; refreshFxNow(true); }; /** v1.3.17 비밀번호 규칙 설명 (서버 lib/passpolicy describe 와 같은 문장) */ function ppDescribe(p) { const parts = [TF('{1}자 이상', p.minLength)]; if (p.maxLength < 256) parts.push(TF('{1}자 이하', p.maxLength)); if (p.requireLetter) parts.push(T('영문자 포함')); if (p.requireMixedCase) parts.push(T('대문자·소문자 모두 포함')); if (p.requireDigit) parts.push(T('숫자 포함')); if (p.requireSymbol) parts.push(T('특수문자 포함')); if (p.forbidId) parts.push(T('아이디를 포함하지 않음')); return parts.join(', '); } /* v1.4.0 도메인 서비스 설정 — 값마다 자기 저장 버튼 (금액·비율은 바로 옆 버튼으로만 바뀐다) */ function fillDomains(d) { if (!d || !$('dmMargin')) return; $('dmMargin').value = d.marginPct; $('dmPlatform').value = d.platformDomain || ''; $('dmIp').value = d.targetIp || ''; // v1.5.36: DNS 공급자·등록 업체 선택 상자 (준비 여부 표시). 목록은 /api/admin/settings 의 domainProviders { const cat = (settingsData && settingsData.domainProviders) || { dns: [], registrar: [] }; const fill = (sel, items, cur) => { sel.innerHTML = ''; for (const it of items) sel.appendChild(new Option(`${it.label}${it.id === 'manual' ? '' : (it.ready ? ' — 준비됨' : ' — 접속 정보 없음')}`, it.id)); sel.value = cur; if (sel.value !== cur && items.length) sel.value = items[0].id; }; fill($('dmDns'), cat.dns, d.dnsProvider || d.platformDns || 'manual'); fill($('dmRegistrar'), cat.registrar, d.registrar || 'openprovider'); $('dmProvNote').textContent = '접속 정보는 왼쪽 "외부 연동 접속 정보" 에서 종류 Openprovider · Porkbun · Route 53 · Google Cloud DNS 로 넣고 "연결 확인" 으로 검사합니다. 구매한 도메인의 영역은 DNS 공급자에 만들어지고, 등록 업체와 공급자가 다르면 네임서버를 자동으로 지정합니다.'; const dz = $('dzProvider'); if (dz) { dz.innerHTML = ''; for (const it of cat.dns.filter((x) => x.id !== 'manual')) dz.appendChild(new Option(`${it.label}${it.ready ? '' : ' (접속 정보 없음)'}`, it.id)); dz.value = (d.dnsProvider && d.dnsProvider !== 'manual') ? d.dnsProvider : (cat.dns.find((x) => x.ready && x.id !== 'manual') || {}).id || ''; } } $('dmTlds').value = (d.tlds || []).join(', '); $('dmReserved').value = (d.reserved || []).join(', '); $('dmDefType').value = d.defaultAccountType; $('dmUserMail').checked = d.userMail !== false; if ($('dmAllowSub')) $('dmAllowSub').checked = d.allowSubdomain !== false; // v1.6.35 (요구 1-1) 서브 도메인 등록 허용 } if ($('btnDmAllowSub')) $('btnDmAllowSub').onclick = () => saveDomains({ allowSubdomain: $('dmAllowSub').checked }, 'dmAllowSubMsg', (d) => (d.allowSubdomain !== false ? T('저장했습니다 — 서브 도메인 등록 허용') : T('저장했습니다 — 서브 도메인 등록 금지 (기존 등록은 유지)'))); const saveDomains = (patch, msgId, done) => saveSetting({ domains: patch }, msgId, (s) => { fillDomains(s.domainsConfig); return done(s.domainsConfig || {}); }); $('btnDmMargin').onclick = () => saveDomains({ marginPct: $('dmMargin').value }, 'dmMarginMsg', (d) => TF('저장했습니다 — 사용자 가격 = 공급사 가격 × {1}%', d.marginPct)); $('btnDmPlatform').onclick = () => saveDomains({ platformDomain: $('dmPlatform').value, dnsProvider: $('dmDns').value, registrar: $('dmRegistrar').value }, 'dmPlatformMsg', (d) => TF('저장했습니다 — {1} (DNS {2} · 등록 {3})', d.platformDomain || '-', d.dnsProvider || 'manual', d.registrar || 'openprovider')); // v1.5.36: DNS 공급자·등록 업체 함께 저장 $('btnDmIp').onclick = () => saveDomains({ targetIp: $('dmIp').value }, 'dmIpMsg', (d) => (d.targetIp ? TF('저장했습니다 — {1}', d.targetIp) : T('비웠습니다 — 서비스 주소의 IP 를 씁니다'))); $('btnDmTlds').onclick = () => saveDomains({ tlds: $('dmTlds').value }, 'dmTldsMsg', (d) => TF('저장했습니다 — {1}', (d.tlds || []).join(', '))); $('btnDmReserved').onclick = () => saveDomains({ reserved: $('dmReserved').value }, 'dmReservedMsg', (d) => TF('저장했습니다 — {1}개', (d.reserved || []).length)); $('btnDmDefType').onclick = () => saveDomains({ defaultAccountType: $('dmDefType').value }, 'dmDefTypeMsg', (d) => TF('저장했습니다 — 새 가입자: {1}', d.defaultAccountType === 'domain' ? T('자체 도메인') : T('서브 도메인 전용'))); $('btnDmUserMail').onclick = () => saveDomains({ userMail: $('dmUserMail').checked }, 'dmUserMailMsg', (d) => (d.userMail ? T('저장했습니다 — 켜짐') : T('저장했습니다 — 꺼짐'))); $('btnSavePp').onclick = () => saveSetting({ passwordPolicy: { minLength: $('ppMin').value, maxLength: $('ppMax').value, requireLetter: $('ppLetter').checked, requireMixedCase: $('ppMixed').checked, requireDigit: $('ppDigit').checked, requireSymbol: $('ppSymbol').checked, forbidId: $('ppNoId').checked } }, 'ppMsg', (s) => TF('저장했습니다 — {1}', ppDescribe({ minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) }))); $('btnSaveMargin').onclick = () => saveSetting({ marginPct: $('sMargin').value }, 'marginMsg', (s) => TF('마진 {1}% 로 변경했습니다', s.marginPct)).then(() => { billingData = null; }); // ---------- v1.4.14 (요구 9) 플랫폼 도메인 와일드카드 인증서 ---------- /** * 상태를 받아 그린다. 발급이 진행 중이면 10초마다 다시 받아 수동 DNS 모드의 "넣을 TXT" 와 결과를 보인다. * cert.hasCert → 만료일·남은 일수, pending → TXT 이름·값, last → 마지막 발급 결과 */ let sslTimer = null; async function loadSsl() { let st; try { st = await api('/api/admin/ssl'); } catch (e) { $('sslStatus').textContent = T('상태를 불러오지 못했습니다') + ': ' + e.message; return; } if (st.platformDomain) $('sslPlatformEx').textContent = `이름.${st.platformDomain}`; const parts = []; parts.push(st.platformDomain ? TF('플랫폼 도메인 {1} · 인증서 이름 {2}', st.platformDomain, st.certName) : T('플랫폼 도메인 없음')); parts.push(TF('DNS 인증 방식: {1}', st.dnsMode === 'openprovider' ? T('자동 (Openprovider)') : T('수동 (TXT 직접 입력)'))); if (st.target) parts.push(TF('배포 서버: {1}', st.target.name)); if (st.cert && st.cert.hasCert) parts.push(TF('인증서 있음 — {1}, 만료 {2} ({3}일 남음)', (st.cert.sans || []).join(', '), st.cert.expires || '?', st.cert.daysLeft == null ? '?' : st.cert.daysLeft) + (st.external ? ' · ' + TF('서버에 미리 발급된 인증서 {1} 를 씁니다 (서브 도메인에 자동 적용, 갱신은 그 인증서의 certbot 타이머)', st.certName) : '')); // v1.5.17 if (st.autoApply && st.autoApply.reconfigured) parts.push(TF('기동 때 자동 적용: {1} — 사이트 {2}개 HTTPS 로 갱신', dt(st.autoApply.at), st.autoApply.reconfigured)); else if (st.supported) parts.push(T('인증서 없음 — 서브 도메인 사이트는 지금 HTTP 로 열립니다')); if (!st.supported && st.reason) parts.push(st.reason); if (st.running) parts.push(TF('발급 진행 중 ({1} 시작)', dt(st.job.startedAt))); if (st.last) parts.push(`${T('마지막 발급')}: ${dt(st.last.at)} ${st.last.ok ? T('성공') : T('실패')} — ${st.last.detail}${st.last.reconfigured ? ` · ${TF('사이트 {1}개 설정 갱신', st.last.reconfigured)}` : ''}`); if (st.cert && st.cert.hasCert && st.cert.daysLeft != null && st.cert.daysLeft < 20) parts.push(T('⚠ 만료가 가까워 갱신이 필요합니다 — 수동 DNS 방식이면 "발급·갱신" 을 누르고 뜨는 TXT 를 넣어 주세요')); $('sslStatus').textContent = parts.join(' · '); $('sslStatus').style.color = st.cert && st.cert.hasCert && (st.cert.daysLeft == null || st.cert.daysLeft >= 20) ? '#6ee7b7' : (st.supported ? '' : '#fca5a5'); const pend = st.pending; $('sslPending').classList.toggle('hidden', !pend); if (pend) { $('sslTxtName').value = pend.name; $('sslTxtValue').value = pend.value; $('sslPendingNote').textContent = pend.auto ? T('(자동 등록됨 — 전파 확인 중)') : TF('— DNS 관리 화면에 넣으면 자동으로 이어집니다 (최대 {1}분 대기, {2} 요청)', pend.waitMinutes || st.waitMinutes, dt(pend.at)); } $('sslIssue').disabled = !st.supported || st.running; $('sslCancel').classList.toggle('hidden', !st.running); clearTimeout(sslTimer); if (st.running) sslTimer = setTimeout(loadSsl, 10000); } $('sslRefresh').onclick = loadSsl; $('sslIssue').onclick = async () => { if (!confirm(T('와일드카드 인증서를 발급(또는 갱신)합니다. 수동 DNS 방식이면 잠시 뒤 TXT 레코드가 표시되며, DNS 에 넣기 전까지 진행이 멈춰 있습니다. 계속할까요?'))) return; $('sslIssue').disabled = true; $('sslMsg').textContent = T('시작 중…'); try { const r = await api('/api/admin/ssl/wildcard', { method: 'POST', body: '{}' }); $('sslMsg').textContent = r.mode === 'manual' ? TF('시작했습니다 — 몇 초 뒤 위에 TXT 레코드가 표시됩니다 (최대 {1}분 대기)', r.waitMinutes) : T('시작했습니다 — DNS 에 TXT 를 자동으로 넣고 인증합니다 (수 분)'); setTimeout(loadSsl, 4000); } catch (e) { $('sslMsg').textContent = T('실패') + ': ' + e.message; $('sslIssue').disabled = false; } }; $('sslCancel').onclick = async () => { try { await api('/api/admin/ssl/wildcard', { method: 'DELETE' }); $('sslMsg').textContent = T('취소했습니다'); setTimeout(loadSsl, 2000); } catch (e) { $('sslMsg').textContent = e.message; } }; document.querySelectorAll('[data-copy]').forEach((b) => { b.onclick = async () => { const v = $(b.dataset.copy).value; try { await navigator.clipboard.writeText(v); b.textContent = T('복사됨'); setTimeout(() => (b.textContent = T('복사')), 1200); } catch (_) { prompt('', v); } }; }); $('btnSaveLeEmail').onclick = () => saveSetting({ letsEncryptEmail: $('sLeEmail').value }, 'leMsg', (s) => (s.letsEncryptEmail ? TF('저장했습니다 — {1}', s.letsEncryptEmail) : T('비웠습니다 (이메일 없이 발급)'))); $('btnSaveInternal').onclick = () => saveSetting({ internalRef: $('sInternalRef').value }, 'internalMsg', (s) => TF('저장했습니다 — {1}', s.internalRef)).then(() => loadOverview()); // v1.5.16 내부 호출 모델 /** * v1.5.16 (요구 3) AI 연결 탭 › 사용할 Claude 모델: Claude 가격표(settings.MODELS)를 체크 목록으로 보이고, 체크한 id 배열을 settings.claudeModels 로 저장한다. * 비어 있으면(= 저장한 적 없음) 전체 사용으로 본다. 저장 뒤 loadSettings 가 등급·내부 호출 목록을 다시 그린다. */ function renderClaudeModels(s) { const box = $('claudeModelRows'); if (!box) return; box.innerHTML = ''; const on = new Set((s.claudeModels && s.claudeModels.length ? s.claudeModels : (s.models || []).map((m) => m.id))); for (const m of s.models || []) { const l = document.createElement('label'); l.className = 'radio'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = m.id; cb.checked = on.has(m.id); cb.dataset.claudeModel = '1'; l.appendChild(cb); l.appendChild(document.createTextNode(` ${m.label} (${m.id}) — 입력 ${m.input} / 출력 ${m.output} per 1M${m.note ? ' · ' + m.note : ''}`)); box.appendChild(l); } const used = new Set(Object.values(s.tiers || {}).concat([s.internalRef || '']).filter((r) => String(r).startsWith('anthropic:')).map((r) => r.slice(10))); const note = document.createElement('p'); note.className = 'muted'; note.textContent = used.size ? TF('지금 등급·내부 호출에 지정된 Claude 모델: {1} — 이 모델을 끄면 설정 탭에서 "현재 사용 불가" 로 보입니다', [...used].join(', ')) : ''; box.appendChild(note); } $('btnSaveClaudeModels').onclick = () => { const ids = [...document.querySelectorAll('#claudeModelRows input[data-claude-model]')].filter((c) => c.checked).map((c) => c.value); if (!ids.length) { $('claudeModelsMsg').textContent = T('적어도 한 모델은 사용으로 두세요'); $('claudeModelsMsg').className = 'msg err'; return; } saveSetting({ claudeModels: ids }, 'claudeModelsMsg', (s) => TF('저장했습니다 — 사용 {1}개 (설정 탭의 등급·내부 호출 목록에 반영)', (s.claudeModels || []).length || (s.models || []).length)); }; /** * v1.3.16 저장하지 않은 변경 표시: data-dirty-watch 입력칸은 불러온 값(dataset.saved)과 다르면 노란 테두리(.dirty)로 표시하고, * 그런 칸이 있는 채로 페이지를 떠나거나 새로고침하면 브라우저가 확인을 묻는다. */ function markSaved() { document.querySelectorAll('[data-dirty-watch]').forEach((i) => { i.dataset.saved = i.type === 'radio' || i.type === 'checkbox' ? String(i.checked) : i.value; i.classList.remove('dirty'); }); } document.addEventListener('input', (e) => { const i = e.target; if (!i.matches || !i.matches('[data-dirty-watch]')) return; if (i.type === 'radio') { document.querySelectorAll(`input[name="${i.name}"]`).forEach((r) => r.classList.toggle('dirty', String(r.checked) !== r.dataset.saved)); return; } i.classList.toggle('dirty', (i.type === 'checkbox' ? String(i.checked) : i.value) !== i.dataset.saved); }); document.addEventListener('change', (e) => { if (e.target.matches && e.target.matches('select[data-dirty-watch], input[type=radio][data-dirty-watch]')) e.target.dispatchEvent(new Event('input', { bubbles: true })); }); window.addEventListener('beforeunload', (e) => { if (document.querySelector('.dirty')) { e.preventDefault(); e.returnValue = ''; } }); $('btnRefreshFx').onclick = () => refreshFxNow(true); /** * 서비스 버전 감시 (v1.3.5). 관리자 화면을 열어 둔 채 서비스가 새 버전으로 배포되면, 화면의 데이터(언어 상태·원본 버전 등)가 * 예전 값으로 남아 "표시 중", "v1.3.1" 처럼 현재와 다르게 보였다. 1분마다 버전을 확인해 바뀌면 지금 탭과 개요를 다시 불러오고, * 새 화면 코드를 받으려면 페이지를 새로 열라고 띠로 알린다. */ let loadedVersion = null; setInterval(async () => { let v; try { v = (await api('/api/config')).version; } catch (_) { return; } if (!loadedVersion) { loadedVersion = v; return; } if (v && v !== loadedVersion) { const bar = $('verBar'); bar.classList.remove('hidden'); bar.textContent = ''; bar.appendChild(document.createTextNode(`서비스가 v${loadedVersion} → v${v} 로 업데이트되어 화면 데이터를 다시 불러왔습니다. 새 기능 화면까지 받으려면 `)); const a = document.createElement('button'); a.type = 'button'; a.className = 'ghost'; a.textContent = '페이지 새로 열기'; a.onclick = () => location.reload(); bar.appendChild(a); loadedVersion = v; $('btnReload').click(); } }, 60000); (async () => { const c = await api('/api/config'); me = c.user; loadedVersion = c.version; $('who').textContent = `${me.name} (${me.id})`; settingsData = await api('/api/admin/settings').catch(() => null); await loadOverview(); loadSearchRegs(); { const h = location.hash.slice(1); if (h && h !== 'detail' && document.getElementById('tab-' + h)) openTab(h); } })(); $('fbTypeFilter').onchange = () => { if (fbData) loadFeedback(); }; // ---------- v1.5.7 (요구 2·7) 시스템 설정 탭: 설정 파일·연동 서비스·AI 엔진 — 초기 설정 페이지와 같은 화면(public/setup.js)을 관리자 API 로 붙인다 ---------- // 탭을 처음 열 때 한 번 붙이고, 이후에는 화면 안의 "새로고침" 으로 다시 읽는다 (설정 저장 뒤 서비스를 재시작하면 서버 상태가 바뀌므로). let sysMounted = null; document.querySelectorAll('.tabs button[data-tab="system"]').forEach((b) => b.addEventListener('click', () => { if (!sysMounted && window.WMSetup) sysMounted = WMSetup.mount($('sysConfig'), { apiBase: '/api/admin/config' }); })); /* ============================================================================ * v1.5.36 (요구 5) DNS 영역 관리 (관리자) — /api/admin/dns/* * 목록(영역·네임서버·회원 소유 여부) · 만들기 · 이전된 영역 받기 · 레코드 보기/추가/삭제 · 지우기(회원 도메인은 force 확인) · 감사 기록 * ========================================================================== */ let dzZones = []; let dzDomains = []; let dzCur = null; async function dzLoad() { const prov = $('dzProvider').value; $('dzMsg').textContent = T('불러오는 중…'); try { const info = await api('/api/admin/dns'); dzDomains = info.domains || []; const at = $('dzAudit').querySelector('tbody'); at.innerHTML = ''; for (const a of info.audit || []) { const tr = document.createElement('tr'); td(tr, dt(a.at)); td(tr, a.by || ''); td(tr, a.action); td(tr, a.provider || ''); td(tr, a.zone || ''); td(tr, Array.isArray(a.records) ? a.records.join(' · ') : (a.imported != null ? TF('{1}개 가져옴', a.imported) : ''), 'wrap'); at.appendChild(tr); } } catch (_) {} const tb = $('dzTable').querySelector('tbody'); tb.innerHTML = ''; if (!prov) { $('dzMsg').textContent = T('준비된 DNS 공급자가 없습니다 — 외부 연동 접속 정보에 넣으세요'); return; } try { const r = await api(`/api/admin/dns/zones?provider=${encodeURIComponent(prov)}`); dzZones = r.zones || []; $('dzMsg').textContent = TF('{1}: 영역 {2}개', prov, dzZones.length); } catch (e) { $('dzMsg').textContent = T('실패') + ': ' + e.message; return; } for (const z of dzZones) { const tr = document.createElement('tr'); td(tr, z.name); td(tr, (z.nameServers || []).join(', ') || '-', 'wrap'); const owner = dzDomains.find((d) => d.name === z.name); td(tr, owner ? `${owner.userId}${owner.dnsProvider ? ' (' + owner.dnsProvider + ')' : ''}` : '-'); td(tr, z.records != null ? String(z.records) : '-', 'num'); const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('레코드', 'ghost', () => dzRecords(z.name))); act.appendChild(mkBtn('지우기', 'ghost danger', async () => { if (!confirm(`${z.name} 영역을 ${prov} 에서 지울까요? 레코드가 모두 사라집니다.`)) return; let q = ''; if (owner) { if (!confirm(`이 영역은 회원 ${owner.userId} 의 도메인입니다. 그래도 지울까요?`)) return; q = '&force=1'; } try { await api(`/api/admin/dns/zones/${encodeURIComponent(z.name)}?provider=${encodeURIComponent(prov)}${q}`, { method: 'DELETE' }); dzLoad(); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr); } if (!dzZones.length) { const tr = document.createElement('tr'); td(tr, T('영역이 없습니다')).colSpan = 5; tb.appendChild(tr); } } async function dzRecords(zone) { dzCur = zone; $('dzRecBox').classList.remove('hidden'); $('dzRecTitle').textContent = `${zone} — ${$('dzProvider').value}`; const tb = $('dzRecTable').querySelector('tbody'); tb.innerHTML = ''; try { const r = await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`); for (const rec of r.records || []) { const tr = document.createElement('tr'); td(tr, rec.name || '@'); td(tr, rec.type); td(tr, rec.value, 'wrap'); td(tr, rec.ttl, 'num'); td(tr, rec.prio != null ? rec.prio : '', 'num'); const act = document.createElement('td'); act.className = 'act'; if (!(rec.type === 'NS' && !rec.name) && rec.type !== 'SOA') act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${rec.type} ${rec.name || '@'} ${rec.value} 레코드를 지울까요?`)) return; try { await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'DELETE', body: JSON.stringify({ records: [rec] }) }); dzRecords(zone); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr); } if (!(r.records || []).length) { const tr = document.createElement('tr'); td(tr, T('레코드가 없습니다')).colSpan = 6; tb.appendChild(tr); } } catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 6; tb.appendChild(tr); } } if ($('dzRefresh')) { $('dzRefresh').onclick = dzLoad; $('dzProvider').onchange = dzLoad; // v1.6.35 (요구 1-1): "영역 만들기"·"이전된 영역 받기" 는 사용자 도메인 탭(이미 가진 도메인 등록 › 네임서버 이전)으로 옮겨 관리자 화면에서 뺐다. API(/api/admin/dns/zones POST)는 호환을 위해 남아 있다 $('dzRecAdd').onclick = async () => { if (!dzCur) return; const rec = { name: $('dzRecName').value.trim(), type: $('dzRecType').value, value: $('dzRecValue').value.trim(), prio: $('dzRecPrio').value || undefined }; if (!rec.value) return alert('값을 입력하세요'); try { await api(`/api/admin/dns/records/${encodeURIComponent(dzCur)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'POST', body: JSON.stringify({ records: [rec] }) }); $('dzRecValue').value = ''; dzRecords(dzCur); } catch (e) { alert(e.message); } }; $('dzRecClose').onclick = () => { $('dzRecBox').classList.add('hidden'); dzCur = null; }; } /* ============================================================================ * v1.6.31 (요구 20·21) 운영 복구 · 작업 기록 * ========================================================================== */ async function loadRecovery() { const tb = $('rcTable').querySelector('tbody'); tb.innerHTML = ''; $('rcMsg').textContent = T('불러오는 중…'); let r; try { r = await api('/api/admin/recovery'); } catch (e) { $('rcMsg').textContent = T('실패') + ': ' + e.message; return; } const items = r.items || []; const b = $('rcBadge'); if (b) { b.textContent = items.length ? String(items.length) : ''; b.classList.toggle('hidden', !items.length); } $('rcMsg').textContent = items.length ? TF('{1}건 — 관리자 확인 필요 {2}건', items.length, items.filter((x) => x.needsAdmin).length) : T('정상 완료되지 않은 작업이 없습니다'); const KIND = { deploy: '배포', purchase: '도메인 구매', domain: '도메인 등록', mail: '메일 계정', cert: '인증서', data: '데이터 파일', job: 'AI 작업' }; for (const it of items) { const tr = document.createElement('tr'); td(tr, KIND[it.kind] || it.kind); td(tr, `${it.siteTitle ? it.siteTitle + ' ' : ''}${it.siteId ? '(' + it.siteId.slice(0, 8) + '…) ' : ''}${it.userId || ''}`, 'wrap'); td(tr, (it.needsAdmin ? '⚠ ' : '') + (it.stateLabel || it.state), it.needsAdmin ? 'st-cancelled' : 'st-running'); td(tr, it.at ? dt(it.at) : '-'); td(tr, it.detail || '', 'wrap'); td(tr, it.userTodo || '', 'wrap'); const act = document.createElement('td'); act.className = 'act'; for (const a of it.actions || []) act.appendChild(mkBtn(a.label, 'ghost', async () => { if (!confirm(`${a.label} — 실행할까요?`)) return; try { const res = await api('/api/admin/recovery/action', { method: 'POST', body: JSON.stringify({ kind: it.kind, id: it.id, action: a.id }) }); alert(res.detail || res.message || T('실행했습니다')); loadRecovery(); } catch (e) { alert(e.message); } })); if (it.siteId && it.kind === 'deploy') act.appendChild(mkBtn('사이트', 'ghost', () => openSite(it.siteId))); tr.appendChild(act); tb.appendChild(tr); } } async function loadAudit() { const q = new URLSearchParams(); for (const [k, id] of [['userId', 'auUser'], ['siteId', 'auSite'], ['action', 'auAction'], ['q', 'auQ']]) { const v = $(id).value.trim(); if (v) q.set(k, v); } const tb = $('auTable').querySelector('tbody'); tb.innerHTML = ''; try { const r = await api('/api/admin/audit?' + q.toString()); for (const e of r.items || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, e.by || ''); td(tr, e.action); td(tr, `${e.targetType || ''} ${e.target || ''}${e.userId && e.userId !== e.target ? ' / ' + e.userId : ''}`, 'wrap'); td(tr, e.siteId ? e.siteId.slice(0, 8) + '…' : ''); td(tr, e.ok === false ? '실패' : '완료', e.ok === false ? 'st-error' : 'st-done'); td(tr, e.detail ? (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)).slice(0, 300) : '', 'wrap'); tb.appendChild(tr); } if (!(r.items || []).length) { const tr = document.createElement('tr'); td(tr, T('기록이 없습니다')).colSpan = 7; tb.appendChild(tr); } } catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 7; tb.appendChild(tr); } } if ($('rcRefresh')) { $('rcRefresh').onclick = loadRecovery; $('auSearch').onclick = loadAudit; }