Files
AriWeb-Suite/portal/ui/public/app.js

49 lines
8.3 KiB
JavaScript

/* portal/ui/public/app.js — 고객 포털 공통 화면 스크립트.
* aw.api(method, path, body) : 같은 origin 의 /api/* 호출. CSRF 헤더 자동, 오류는 { ok:false, error, message } 로 던진다.
* aw.state(el, kind, text) : 불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음 공통 표시
* aw.toast(text) : 짧은 알림
* aw.confirmDanger(spec) : 파괴적 작업 확인(대상·영향·입력 확인) → Promise<boolean>
* aw.poll(fn, ms) : 긴 작업 상태를 화면을 떠나지 않고 갱신(탭이 숨겨지면 느리게)
* aw.job(el, job) : 작업 상태 블록(현재 단계·완료 단계·다음 단계·재시도·취소·되돌리기) 렌더
*/
window.aw = (() => {
const csrf = () => (document.querySelector('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const LABEL = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 확인 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' };
function state(el, kind, text, opts = {}) { if (typeof el === 'string') el = document.querySelector(el); if (!el) return; el.innerHTML = `<div class="aw-state ${kind}" role="status"><b>${esc(LABEL[kind] || kind)}</b>${text ? `<p>${esc(text)}</p>` : ''}${opts.retry ? '<button class="btn" data-retry>다시 시도</button>' : ''}${opts.href ? `<a class="btn" href="${esc(opts.href)}">${esc(opts.hrefLabel || '이동')}</a>` : ''}</div>`; if (opts.retry) el.querySelector('[data-retry]').onclick = opts.retry; }
let toastT; function toast(text) { const t = document.getElementById('toast'); t.textContent = text; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 4000); }
function modal(html) { const m = document.getElementById('modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; m.onclick = (e) => { if (e.target === m) close(); }; function close() { m.hidden = true; m.innerHTML = ''; } return { el: m, close }; }
function confirmDanger(spec) {
return new Promise((resolve) => {
const m = modal(`<h2>${esc(spec.title)}</h2><p><b>대상:</b> ${esc(spec.target)}</p>${spec.impacts && spec.impacts.length ? `<p><b>영향:</b></p><ul>${spec.impacts.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>` : ''}${spec.typeToConfirm ? `<p class="f"><label>확인을 위해 <code>${esc(spec.typeToConfirm)}</code> 을(를) 입력하세요</label><input id="dz-in" autocomplete="off"></p>` : ''}<div class="actions"><button class="btn" id="dz-c">취소</button><button class="btn danger" id="dz-ok" ${spec.typeToConfirm ? 'disabled' : ''}>${esc(spec.confirmLabel || '실행')}</button></div>`);
const okb = m.el.querySelector('#dz-ok'); const inp = m.el.querySelector('#dz-in'); if (inp) inp.oninput = () => { okb.disabled = inp.value.trim() !== spec.typeToConfirm; };
m.el.querySelector('#dz-c').onclick = () => { m.close(); resolve(false); }; okb.onclick = () => { m.close(); resolve(true); };
});
}
function poll(fn, ms = 3000) { let stop = false; (async function loop() { while (!stop) { try { const more = await fn(); if (more === false) break; } catch (e) { /* 화면은 상태 패널이 보여 준다 */ } await new Promise((r) => setTimeout(r, document.hidden ? ms * 4 : ms)); } })(); return () => { stop = true; }; }
const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' };
const SYM = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' };
function fmt(iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }); }
function job(el, j, { onAction } = {}) {
if (typeof el === 'string') el = document.querySelector(el); if (!el || !j) return;
const kind = ['done'].includes(j.status) ? 'done' : ['failed'].includes(j.status) ? 'failed' : ['running', 'verifying', 'reverting'].includes(j.status) ? 'running' : ['awaiting_external', 'needs_user', 'paused', 'awaiting_approval', 'awaiting_payment'].includes(j.status) ? 'waiting' : 'neutral';
el.innerHTML = `<div class="job"><div class="ph-row"><span class="badge ${kind}">${SYM[kind === 'waiting' ? 'waiting' : kind] || '○'} ${esc(JOB_LABEL[j.status] || j.status)}</span><span class="sub">${j.stale ? '정보 확인 지연 · ' : ''}마지막 갱신 ${esc(fmt(j.updatedAt))}</span></div>
${j.progress ? `<div class="bar"><i style="width:${j.progress}%"></i></div>` : ''}
<ul class="steps">${(j.steps || []).map((s) => `<li class="${esc(s.state)}"><span class="s" aria-hidden="true">${SYM[s.state] || '○'}</span><span>${esc(s.title)}${s.detail ? ` <span class="sub">— ${esc(s.detail)}</span>` : ''}${s.error ? ` <span class="err">— ${esc(s.error)}</span>` : ''}</span></li>`).join('')}</ul>
<dl class="kv">${j.currentStep ? `<dt>현재 단계</dt><dd>${esc(j.currentStep.title)}</dd>` : ''}${j.nextStep ? `<dt>다음 단계</dt><dd>${esc(j.nextStep.title)}</dd>` : ''}<dt>예상 완료</dt><dd>${j.etaAt ? esc(fmt(j.etaAt)) : esc(j.etaUnknownReason || '산정할 수 없음')}</dd>${j.maxAttempts > 1 ? `<dt>자동 재시도</dt><dd>${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ` · 다음 ${esc(fmt(j.nextRunAt))}` : ''}</dd>` : ''}${j.waitReason ? `<dt>대기 사유</dt><dd>${esc(j.waitReason)}</dd>` : ''}${j.userMessage ? `<dt>필요한 조치</dt><dd>${esc(j.userMessage)}</dd>` : ''}${j.error ? `<dt>오류</dt><dd class="err">${esc(j.error)} <span class="sub">(문의 번호 ${esc(j.id)})</span></dd>` : ''}</dl>
<div class="actions">${(j.actions || []).map((a) => `<button class="btn primary" data-ja="${esc(a.id)}">${esc(a.label)}</button>`).join('')}${j.canRetry ? '<button class="btn" data-ja="retry">다시 시도</button>' : ''}${j.canResume && !(j.actions || []).length ? '<button class="btn" data-ja="resume">이어서 진행</button>' : ''}${j.canCancel ? '<button class="btn danger" data-ja="cancel">취소</button>' : ''}${j.canRevert ? '<button class="btn danger" data-ja="revert">되돌리기</button>' : ''}</div></div>`;
el.querySelectorAll('[data-ja]').forEach((b) => { b.onclick = () => onAction && onAction(b.dataset.ja, j); });
}
document.addEventListener('click', (e) => { const a = e.target.closest('[data-action]'); if (!a) return; if (a.dataset.action === 'toggle-nav') document.getElementById('side')?.classList.toggle('open'); if (a.classList.contains('copy')) { navigator.clipboard?.writeText(a.dataset.copy || a.textContent); toast('복사했습니다'); } });
document.addEventListener('change', async (e) => { if (e.target.matches('[data-action=switch-group]')) { try { await api('POST', '/api/groups/switch', { groupId: e.target.value }); location.reload(); } catch (err) { toast(err.message); } } });
// 저장하지 않은 변경의 이탈 경고(§9.3): 폼이 data-dirty-guard 를 가지면 입력 뒤 떠날 때 경고
let dirty = false; document.addEventListener('input', (e) => { if (e.target.closest('form[data-dirty-guard]')) dirty = true; }); document.addEventListener('submit', () => { dirty = false; }); window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
return { api, esc, state, toast, modal, confirmDanger, poll, job, fmt, JOB_LABEL, markClean: () => { dirty = false; } };
})();