chore(lps): 임시 검색 콘솔 FE(lps-temp-fe) — API/그래프/몰별·배송 확인용
API 통신 확인용 단일 파일 프론트(외부 CDN 0, SVG 차트 자체 구현). 검색 요청 → 잡 폴링(파이프라인 퍼널·소스별 건수) → 최저가 히어로/상품표, 최저가 이력 3선 그래프(네이버·쿠팡·최종), 몰별 최저가 막대, 배송 뱃지, 헤더 헬스·큐 상태 실시간. CORS 매칭 위해 :5173 서빙(serve.sh, 대화형). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8460a5d833
commit
7806b35723
629
lps-temp-fe/index.html
Normal file
629
lps-temp-fe/index.html
Normal file
@ -0,0 +1,629 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS · 최저가 검색 콘솔 (임시 FE)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f6f8; --surface:#ffffff; --surface-2:#fafbfc;
|
||||
--border:#e6e8ec; --border-2:#eef0f3;
|
||||
--text-900:#1a1d24; --text-700:#3d434f; --text-500:#6b7280; --text-400:#9aa1ac;
|
||||
--primary-600:#4f46e5; --primary-500:#635bff; --primary-50:#eef0ff;
|
||||
--naver:#03c75a; --coupang:#f0455b; --final:#4f46e5;
|
||||
--ok-600:#0f9d58; --ok-50:#e7f6ee;
|
||||
--warn-600:#c77800; --warn-50:#fdf3e3;
|
||||
--run-600:#2563eb; --run-50:#e8f0ff;
|
||||
--dead-600:#dc2626; --dead-50:#fdeaea;
|
||||
--radius:14px; --radius-sm:9px;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06);
|
||||
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--text-900);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-600);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
/* ---- layout ---- */
|
||||
header.top{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.82);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
|
||||
.top-inner{max-width:1180px;margin:0 auto;padding:12px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16px}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--primary-500),#8b7bff);display:grid;place-items:center;color:#fff;font-size:15px;font-weight:800}
|
||||
.brand small{display:block;font-weight:500;font-size:11px;color:var(--text-400);letter-spacing:.02em}
|
||||
.top-spacer{flex:1}
|
||||
.stat-badges{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700)}
|
||||
.badge .dot{width:7px;height:7px;border-radius:50%;background:var(--text-400)}
|
||||
.badge b{font-variant-numeric:tabular-nums}
|
||||
.badge.pending .dot{background:var(--warn-600)} .badge.running .dot{background:var(--run-600)}
|
||||
.badge.done .dot{background:var(--ok-600)} .badge.dead .dot{background:var(--dead-600)}
|
||||
.health{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--text-500);font-weight:600}
|
||||
.pulse{width:8px;height:8px;border-radius:50%;background:var(--dead-600)}
|
||||
.pulse.up{background:var(--ok-600);box-shadow:0 0 0 0 rgba(15,157,88,.5);animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(15,157,88,.45)}70%{box-shadow:0 0 0 7px rgba(15,157,88,0)}100%{box-shadow:0 0 0 0 rgba(15,157,88,0)}}
|
||||
|
||||
main{max-width:1180px;margin:0 auto;padding:22px;display:grid;grid-template-columns:400px 1fr;gap:20px;align-items:start}
|
||||
@media (max-width:940px){main{grid-template-columns:1fr}}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.card + .card{margin-top:20px}
|
||||
.card-head{padding:15px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px}
|
||||
.card-head h2{margin:0;font-size:14px;font-weight:700;letter-spacing:-.01em}
|
||||
.card-head .hint{margin-left:auto;font-size:11.5px;color:var(--text-400);font-weight:500}
|
||||
.card-head .idx{width:22px;height:22px;border-radius:7px;background:var(--primary-50);color:var(--primary-600);display:grid;place-items:center;font-size:12px;font-weight:800}
|
||||
.card-body{padding:18px}
|
||||
|
||||
/* ---- form ---- */
|
||||
.field{margin-bottom:13px}
|
||||
.field:last-child{margin-bottom:0}
|
||||
.field label{display:block;font-size:12px;font-weight:600;color:var(--text-700);margin-bottom:5px}
|
||||
.field label .req{color:var(--coupang);margin-left:2px}
|
||||
.field .sub{font-weight:500;color:var(--text-400);font-size:11px}
|
||||
input,select,textarea{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-900);background:var(--surface);transition:border-color .12s,box-shadow .12s}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-500);box-shadow:0 0 0 3px var(--primary-50)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:11px}
|
||||
.btn{appearance:none;border:1px solid transparent;border-radius:var(--radius-sm);font-family:inherit;font-size:13px;font-weight:600;padding:10px 15px;cursor:pointer;transition:background .12s,border-color .12s,color .12s;display:inline-flex;align-items:center;justify-content:center;gap:7px}
|
||||
.btn-primary{background:var(--primary-600);color:#fff;width:100%}
|
||||
.btn-primary:hover{background:var(--primary-500)}
|
||||
.btn-primary:disabled{background:#c3c6cf;cursor:not-allowed}
|
||||
.btn-ghost{background:var(--surface);border-color:var(--border);color:var(--text-700)}
|
||||
.btn-ghost:hover{background:var(--surface-2)}
|
||||
.btn-sm{padding:7px 11px;font-size:12px}
|
||||
|
||||
.examples{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
|
||||
.chip{font-size:11.5px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700);cursor:pointer}
|
||||
.chip:hover{border-color:var(--primary-500);color:var(--primary-600);background:var(--primary-50)}
|
||||
|
||||
/* ---- job tracking ---- */
|
||||
.empty{padding:38px 20px;text-align:center;color:var(--text-400)}
|
||||
.empty .big{font-size:30px;margin-bottom:8px;opacity:.5}
|
||||
.job-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.job-id{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--border-2);padding:4px 8px;border-radius:6px;color:var(--text-700)}
|
||||
.status-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:700}
|
||||
.status-pill.PENDING{background:var(--warn-50);color:var(--warn-600)}
|
||||
.status-pill.RUNNING{background:var(--run-50);color:var(--run-600)}
|
||||
.status-pill.DONE{background:var(--ok-50);color:var(--ok-600)}
|
||||
.status-pill.DEAD{background:var(--dead-50);color:var(--dead-600)}
|
||||
.status-pill .spin{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.outcome-hero{display:flex;align-items:flex-end;gap:14px;padding:16px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#f7f8ff,#f0f3ff);border:1px solid var(--border-2);margin-bottom:16px}
|
||||
.outcome-hero.nf{background:linear-gradient(135deg,#fbfbfc,#f4f5f7)}
|
||||
.price-big{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums}
|
||||
.price-big .won{font-size:18px;font-weight:700;color:var(--text-500);margin-left:2px}
|
||||
.src-tag{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px;color:#fff;text-transform:uppercase;letter-spacing:.03em}
|
||||
.src-tag.naver{background:var(--naver)} .src-tag.coupang{background:var(--coupang)}
|
||||
.ship-tag{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px;border:1px solid transparent;white-space:nowrap}
|
||||
.ship-tag.rocket{background:#eef4ff;color:#2f6fed;border-color:#d5e3ff}
|
||||
.ship-tag.rocket_merchant{background:#f3eeff;color:#7a4fe0;border-color:#e3d8ff}
|
||||
.ship-tag.free{background:var(--ok-50);color:var(--ok-600)}
|
||||
.ship-tag.paid{background:#fbecec;color:var(--dead-600)}
|
||||
.ship-tag.unknown{background:var(--surface-2);color:var(--text-400);border-color:var(--border-2)}
|
||||
.outcome-label{font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
|
||||
|
||||
/* funnel / stages */
|
||||
.stages{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.stage{flex:1;min-width:82px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px;text-align:center;position:relative}
|
||||
.stage .st-name{font-size:11px;color:var(--text-500);font-weight:600;margin-bottom:4px;text-transform:capitalize}
|
||||
.stage .st-io{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.stage .st-io .arr{color:var(--text-400);font-weight:500;margin:0 3px}
|
||||
.stage .st-drop{font-size:10.5px;color:var(--coupang);font-weight:600;margin-top:2px}
|
||||
|
||||
.src-counts{display:flex;gap:8px;margin-bottom:16px}
|
||||
.src-count{flex:1;border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 12px;background:var(--surface-2)}
|
||||
.src-count .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px}
|
||||
.src-count .lbl.naver{color:var(--naver)} .src-count .lbl.coupang{color:var(--coupang)}
|
||||
.src-count .n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.src-count .err{font-size:11px;color:var(--dead-600);font-weight:600}
|
||||
|
||||
/* 몰별 최저가 분해 */
|
||||
.mall-bars{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
|
||||
.mall-bar{display:flex;align-items:center;gap:10px}
|
||||
.mall-bar .mname{width:112px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text-700);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:5px}
|
||||
.mall-bar .mname .sdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.mall-bar .track{flex:1;height:22px;background:var(--surface-2);border-radius:6px;position:relative;overflow:hidden}
|
||||
.mall-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;opacity:.16}
|
||||
.mall-bar .mprice{position:absolute;right:8px;top:0;bottom:0;display:flex;align-items:center;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text-900)}
|
||||
.mall-bar.best .mprice{color:var(--ok-600)}
|
||||
.mall-bar .badge-best{position:absolute;left:8px;top:0;bottom:0;display:flex;align-items:center;font-size:10px;font-weight:800;color:var(--ok-600)}
|
||||
|
||||
.sec-title{font-size:12px;font-weight:700;color:var(--text-700);margin:0 0 9px;display:flex;align-items:center;gap:7px}
|
||||
.sec-title .cnt{font-size:11px;color:var(--text-400);font-weight:600}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.03em;padding:8px 10px;border-bottom:1px solid var(--border-2)}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border-2);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.price-cell{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.name-cell{max-width:340px}
|
||||
.name-cell a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rank{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--surface-2);border:1px solid var(--border-2);font-size:11px;font-weight:800;color:var(--text-500)}
|
||||
.rank.top{background:var(--primary-50);color:var(--primary-600);border-color:transparent}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chart-controls{display:flex;gap:9px;align-items:flex-end;margin-bottom:16px}
|
||||
.chart-controls .field{flex:1;margin:0}
|
||||
.chart-wrap{position:relative;width:100%;overflow-x:auto}
|
||||
svg.chart{display:block;width:100%;height:auto;min-width:520px}
|
||||
.legend{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
|
||||
.legend-item{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--text-700);cursor:pointer;user-select:none}
|
||||
.legend-item .swatch{width:14px;height:3px;border-radius:2px}
|
||||
.legend-item.off{opacity:.35}
|
||||
.legend-item .swatch.dashed{background:none!important;border-top:2px dashed}
|
||||
|
||||
.chart-tip{position:absolute;pointer-events:none;background:#1a1d24;color:#fff;padding:8px 10px;border-radius:8px;font-size:11.5px;box-shadow:0 6px 24px rgba(0,0,0,.2);opacity:0;transition:opacity .1s;z-index:5;min-width:130px;transform:translate(-50%,-115%)}
|
||||
.chart-tip .tt-time{color:#9aa1ac;font-size:10.5px;margin-bottom:5px}
|
||||
.chart-tip .tt-row{display:flex;justify-content:space-between;gap:12px;font-variant-numeric:tabular-nums}
|
||||
.chart-tip .tt-row .k{display:flex;align-items:center;gap:6px}
|
||||
.chart-tip .tt-dot{width:7px;height:7px;border-radius:50%}
|
||||
|
||||
/* ---- misc ---- */
|
||||
.toast-wrap{position:fixed;bottom:22px;right:22px;z-index:100;display:flex;flex-direction:column;gap:9px}
|
||||
.toast{background:#1a1d24;color:#fff;padding:12px 16px;border-radius:10px;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.24);display:flex;align-items:center;gap:9px;animation:slideIn .25s ease;max-width:340px}
|
||||
.toast.err{background:#b91c1c} .toast.ok{background:#0f9d58}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.muted{color:var(--text-400)}
|
||||
.spin-inline{width:13px;height:13px;border:2px solid var(--border);border-right-color:var(--primary-500);border-radius:50%;display:inline-block;animation:spin .7s linear infinite;vertical-align:-2px}
|
||||
.cfg-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-500)}
|
||||
.cfg-row input{width:auto;flex:1;padding:6px 9px;font-size:12px;font-family:var(--mono)}
|
||||
code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:4px;border:1px solid var(--border-2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<div class="brand">
|
||||
<div class="logo">₩</div>
|
||||
<div>LPS 최저가 검색 콘솔
|
||||
<small>임시 프론트엔드 · API 통신 확인용</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-spacer"></div>
|
||||
<div class="stat-badges" id="queueBadges">
|
||||
<span class="badge pending"><span class="dot"></span>PENDING <b>–</b></span>
|
||||
<span class="badge running"><span class="dot"></span>RUNNING <b>–</b></span>
|
||||
<span class="badge done"><span class="dot"></span>DONE <b>–</b></span>
|
||||
<span class="badge dead"><span class="dot"></span>DEAD <b>–</b></span>
|
||||
</div>
|
||||
<div class="health"><span class="pulse" id="healthDot"></span><span id="healthTxt">확인 중…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ LEFT: 검색 요청 ============ -->
|
||||
<section>
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">1</span><h2>검색 요청</h2><span class="hint">POST /v1/lps/search</span></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>빠른 예시</label>
|
||||
<div class="examples" id="examples"></div>
|
||||
</div>
|
||||
<form id="searchForm">
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>상품 코드 <span class="req">*</span> <span class="sub">이력 키</span></label>
|
||||
<input name="product_code" required placeholder="T1" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>우선순위 <span class="sub">job_type</span></label>
|
||||
<select name="job_type">
|
||||
<option value="new_product">new_product · 1 (최우선)</option>
|
||||
<option value="manual" selected>manual · 2 (기본)</option>
|
||||
<option value="partner">partner · 3</option>
|
||||
<option value="batch">batch · 4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>상품명 <span class="req">*</span></label>
|
||||
<input name="product_name" required placeholder="맥심 모카골드 커피믹스" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>규격 <span class="sub">자유 서술 — AI가 해석</span></label>
|
||||
<input name="specification" placeholder="1박스, 160개입" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>모델명 <span class="sub">선택</span></label>
|
||||
<input name="model" placeholder="모카골드" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>제조사 <span class="sub">선택</span></label>
|
||||
<input name="company" placeholder="동서식품" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>현재가 <span class="sub">있으면 가격밴드 필터 기준</span></label>
|
||||
<input name="price" inputmode="numeric" placeholder="25000" autocomplete="off"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">검색 요청 보내기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>API 설정</h2><span class="hint">Base URL</span></div>
|
||||
<div class="card-body">
|
||||
<div class="cfg-row">
|
||||
<input id="baseUrl" value="http://localhost:9600"/>
|
||||
<button class="btn btn-ghost btn-sm" id="pingBtn">확인</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:10px 0 0;font-size:11.5px">
|
||||
이 페이지는 <code>:5173</code>에서 서빙되어야 CORS가 통과합니다 (<code>serve.sh</code> 참고).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RIGHT: 결과 + 그래프 ============ -->
|
||||
<section>
|
||||
<!-- 작업 추적 / 상품 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">2</span><h2>작업 추적 & 상품 정보</h2><span class="hint" id="jobHint">GET /v1/lps/jobs/{id}</span></div>
|
||||
<div class="card-body" id="jobBody">
|
||||
<div class="empty">
|
||||
<div class="big">📦</div>
|
||||
왼쪽에서 검색을 요청하면 여기에서<br/>진행 상태와 최저가 결과가 실시간으로 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최저가 이력 그래프 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">3</span><h2>최저가 이력 그래프</h2><span class="hint">GET /v1/lps/products/{code}/history</span></div>
|
||||
<div class="card-body">
|
||||
<div class="chart-controls">
|
||||
<div class="field">
|
||||
<label>상품 코드</label>
|
||||
<input id="histCode" placeholder="P1" autocomplete="off"/>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="loadHistBtn" style="height:37px">이력 불러오기</button>
|
||||
</div>
|
||||
<div id="chartArea">
|
||||
<div class="empty"><div class="big">📈</div>상품 코드를 입력하고 이력을 불러오세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast-wrap" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (s,el=document)=>el.querySelector(s);
|
||||
const $$ = (s,el=document)=>Array.from(el.querySelectorAll(s));
|
||||
const base = ()=> $("#baseUrl").value.replace(/\/+$/,"");
|
||||
const won = n => (n==null? "—" : Number(n).toLocaleString("ko-KR"));
|
||||
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
|
||||
/* 배송 뱃지: shipping_type + shipping_fee → 라벨. 네이버는 항상 '배송비 별도'. */
|
||||
function shipBadge(p){
|
||||
const t=p.shipping_type, fee=p.shipping_fee;
|
||||
if(p.source==="naver") return `<span class="ship-tag unknown" title="네이버 오픈API는 배송비 제외 상품가입니다">배송비 별도</span>`;
|
||||
const map={
|
||||
rocket:["rocket","🚀 로켓"], rocket_merchant:["rocket_merchant","🚀 판매자로켓"],
|
||||
free:["free","무료배송"], paid:["paid", fee!=null?`배송비 ${won(fee)}원`:"유료배송"],
|
||||
};
|
||||
if(t && map[t]) return `<span class="ship-tag ${map[t][0]}">${map[t][1]}</span>`;
|
||||
return `<span class="ship-tag unknown">배송비 미확인</span>`;
|
||||
}
|
||||
/* 실구매가(상품가+확정 배송비). 미확인이면 null. */
|
||||
function totalPrice(p){ return (typeof p.shipping_fee==="number") ? p.price+p.shipping_fee : null; }
|
||||
|
||||
/* 판매몰 표시명: 네이버 가격비교(mall='네이버')는 '여러 판매자 최저가 롤업'임을 명시. */
|
||||
function mallLabel(p){
|
||||
if(p.source==="coupang") return "쿠팡";
|
||||
const m=p.mall_name||"기타";
|
||||
return (m==="네이버") ? "네이버 가격비교" : m;
|
||||
}
|
||||
function isCatalog(p){ return p.source==="naver" && (p.mall_name==="네이버"); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
function toast(msg,type=""){
|
||||
const t=document.createElement("div"); t.className="toast "+type; t.textContent=msg;
|
||||
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300)},3200);
|
||||
}
|
||||
|
||||
/* ---------- fetch helper ---------- */
|
||||
async function api(path,opts){
|
||||
const r = await fetch(base()+path, Object.assign({headers:{"Content-Type":"application/json"}},opts));
|
||||
const txt = await r.text();
|
||||
let data; try{ data = txt? JSON.parse(txt):{} }catch(e){ throw new Error("응답 파싱 실패: "+txt.slice(0,120)) }
|
||||
if(!r.ok) throw new Error("HTTP "+r.status+" · "+(data?.result?.desc||txt.slice(0,120)));
|
||||
if(data?.result && data.result.success===false) throw new Error(data.result.desc||"API 실패");
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------- health + queue polling ---------- */
|
||||
async function refreshHealth(){
|
||||
try{
|
||||
const t = await api("/healthz");
|
||||
$("#healthDot").className="pulse up";
|
||||
$("#healthTxt").textContent = "온라인 · "+(typeof t==="string"?t.replace("T"," ").slice(0,19):"");
|
||||
}catch(e){
|
||||
$("#healthDot").className="pulse";
|
||||
$("#healthTxt").textContent="오프라인";
|
||||
}
|
||||
}
|
||||
async function refreshQueue(){
|
||||
try{
|
||||
const d = await api("/v1/lps/queue/stats");
|
||||
const c = d.counts||{};
|
||||
const b = $$("#queueBadges .badge b");
|
||||
b[0].textContent=c.PENDING??0; b[1].textContent=c.RUNNING??0; b[2].textContent=c.DONE??0; b[3].textContent=c.DEAD??0;
|
||||
}catch(e){}
|
||||
}
|
||||
function poll(){ refreshHealth(); refreshQueue(); }
|
||||
poll(); setInterval(poll, 4000);
|
||||
$("#pingBtn").addEventListener("click",()=>{ refreshHealth(); refreshQueue(); toast("연결 확인 중…"); });
|
||||
|
||||
/* ---------- examples ---------- */
|
||||
const EXAMPLES = [
|
||||
{label:"맥심 커피", product_code:"T1", product_name:"맥심 모카골드 커피믹스", specification:"1박스, 160개입", company:"동서식품", model:"모카골드", price:"25000"},
|
||||
{label:"신라면", product_code:"T2", product_name:"농심 신라면", specification:"1박스 40개입", company:"농심", price:"22000"},
|
||||
{label:"P1 (샘플이력)", product_code:"P1", product_name:"테스트 상품 P1", specification:"", price:""},
|
||||
];
|
||||
const exWrap = $("#examples");
|
||||
EXAMPLES.forEach(ex=>{
|
||||
const c=document.createElement("button"); c.type="button"; c.className="chip"; c.textContent=ex.label;
|
||||
c.addEventListener("click",()=>{ for(const k in ex){ if(k==="label")continue; const f=$(`[name=${k}]`); if(f) f.value=ex[k]; } toast("예시 채움: "+ex.label); });
|
||||
exWrap.appendChild(c);
|
||||
});
|
||||
|
||||
/* ---------- submit search ---------- */
|
||||
let pollTimer=null;
|
||||
$("#searchForm").addEventListener("submit", async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target); const item={};
|
||||
fd.forEach((v,k)=> item[k]=String(v).trim());
|
||||
if(!item.product_code||!item.product_name){ toast("상품 코드와 상품명은 필수입니다","err"); return; }
|
||||
const btn=$("#submitBtn"); btn.disabled=true; btn.innerHTML='<span class="spin-inline"></span> 요청 중…';
|
||||
try{
|
||||
const d = await api("/v1/lps/search",{method:"POST",body:JSON.stringify({data:[item]})});
|
||||
const it = (d.items||[])[0]||{};
|
||||
if(it.duplicated){ toast("이미 처리 중인 활성 작업이라 중복 접수 생략됨","err"); }
|
||||
else if(it.job_id){ toast("접수 완료 · 추적 시작","ok"); $("#histCode").value=item.product_code; trackJob(it.job_id, item); }
|
||||
else { toast("접수됐지만 job_id가 없습니다","err"); }
|
||||
}catch(err){ toast(err.message,"err"); }
|
||||
finally{ btn.disabled=false; btn.textContent="검색 요청 보내기"; }
|
||||
});
|
||||
|
||||
/* ---------- job tracking (poll until DONE/DEAD) ---------- */
|
||||
function trackJob(jobId, submitted){
|
||||
if(pollTimer) clearInterval(pollTimer);
|
||||
$("#jobHint").textContent = "job "+jobId.slice(0,8)+"…";
|
||||
let tries=0;
|
||||
const tick = async ()=>{
|
||||
tries++;
|
||||
try{
|
||||
const d = await api("/v1/lps/jobs/"+jobId);
|
||||
renderJob(d, submitted);
|
||||
if(d.status==="DONE"||d.status==="DEAD"){
|
||||
clearInterval(pollTimer); pollTimer=null;
|
||||
if(d.status==="DONE" && d.output?.outcome==="found") loadHistory(submitted.product_code);
|
||||
}
|
||||
}catch(err){
|
||||
renderJobError(err.message);
|
||||
if(tries>3){ clearInterval(pollTimer); pollTimer=null; }
|
||||
}
|
||||
};
|
||||
tick(); pollTimer=setInterval(tick, 1500);
|
||||
}
|
||||
function renderJobError(msg){ $("#jobBody").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(msg)}</div>`; }
|
||||
|
||||
function renderJob(d, submitted){
|
||||
const st=d.status||"PENDING";
|
||||
const o=d.output||null;
|
||||
const spinning=(st==="PENDING"||st==="RUNNING");
|
||||
let html = `<div class="job-meta">
|
||||
<span class="status-pill ${st}">${spinning?'<span class="spin"></span>':''}${st}</span>
|
||||
<span class="job-id">${esc(d.job_id||"")}</span>
|
||||
<span class="muted" style="font-size:12px">시도 ${d.attempts??0}/${d.max_attempts??"–"}</span>
|
||||
</div>`;
|
||||
|
||||
if(!o && spinning){
|
||||
html += `<div class="empty" style="padding:26px"><span class="spin-inline"></span> 워커가 네이버·쿠팡을 검색하고 AI가 같은 상품을 판정하는 중…
|
||||
<div class="muted" style="margin-top:8px;font-size:11.5px">쿠팡 크롤링은 수 초~수십 초 걸릴 수 있습니다.</div></div>`;
|
||||
$("#jobBody").innerHTML=html; return;
|
||||
}
|
||||
if(d.last_error && st==="DEAD"){
|
||||
html += `<div class="outcome-hero nf"><div><div class="outcome-label">작업 실패 (DEAD)</div><div class="muted" style="font-size:13px">${esc(d.last_error)}</div></div></div>`;
|
||||
}
|
||||
if(o){
|
||||
const found = o.outcome==="found";
|
||||
const low = o.lowest;
|
||||
// hero
|
||||
html += `<div class="outcome-hero ${found?'':'nf'}">
|
||||
<div style="flex:1">
|
||||
<div class="outcome-label">${found?'최저가 발견':(o.cached?'네거티브 캐시 (최근 없음)':'같은 상품 없음 · not_found')}</div>
|
||||
${ found && low ? `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<div class="price-big">${won(low.price)}<span class="won">원</span></div>
|
||||
<span class="src-tag ${low.source}">${low.source}</span>
|
||||
${shipBadge(low)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:11.5px;margin-top:5px">판매몰 <b style="color:var(--text-700)">${esc(mallLabel(low))}</b>${ isCatalog(low)?` <span title="여러 판매자 중 최저가 롤업 · 배송비 제외">· 여러 판매자 최저가</span>`:``}</div>
|
||||
${ totalPrice(low)!=null && low.shipping_fee>0 ? `<div class="muted" style="font-size:11.5px;margin-top:2px">실구매가(배송비 포함) <b style="color:var(--text-700)">${won(totalPrice(low))}원</b></div>`:``}
|
||||
<div class="muted" style="font-size:12px;margin-top:6px">${esc(low.name||"")}</div>`
|
||||
: `<div class="price-big muted" style="font-size:22px">—</div>` }
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="muted" style="font-size:11px">검색어</div>
|
||||
<div style="font-weight:600;font-size:13px">${esc(o.query||submitted?.product_name||"")}</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">${o.rounds_tried??0} 라운드${o.round?' · '+esc(o.round):''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// source counts
|
||||
if(o.sources){
|
||||
html += `<div class="src-counts">`;
|
||||
for(const src of ["naver","coupang"]){
|
||||
const s=o.sources[src];
|
||||
html += `<div class="src-count"><div class="lbl ${src}">${src}</div>`;
|
||||
if(s && s.error!==undefined) html+=`<div class="err">차단/오류</div>`;
|
||||
else html += `<div class="n">${s?.count??0}<span class="muted" style="font-size:12px;font-weight:600"> 건</span></div>`;
|
||||
html+=`</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// pipeline stages
|
||||
if(o.stages && o.stages.length){
|
||||
html += `<div class="sec-title">파이프라인 단계 <span class="cnt">몇 건이 걸러졌나</span></div><div class="stages">`;
|
||||
o.stages.forEach(s=>{
|
||||
const drop=(s.in??0)-(s.out??0);
|
||||
html += `<div class="stage"><div class="st-name">${esc(s.stage)}</div>
|
||||
<div class="st-io">${s.in??0}<span class="arr">→</span>${s.out??0}</div>
|
||||
${drop>0?`<div class="st-drop">−${drop}</div>`:``}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// 몰별 최저가 분해 (네이버 payload에 이미 들어온 G마켓/옥션/11번가 등을 추가요청 0으로 노출)
|
||||
const byMall=o.by_mall||[];
|
||||
if(byMall.length>1){
|
||||
const maxP=Math.max(...byMall.map(m=>m.price));
|
||||
html += `<div class="sec-title">몰별 최저가 <span class="cnt">네이버 결과에 포함된 판매몰 · 추가 조회 없음</span></div><div class="mall-bars">`;
|
||||
byMall.forEach((m,i)=>{
|
||||
const w=Math.max(8,Math.round(m.price/maxP*100));
|
||||
const color=m.source==="coupang"?"var(--coupang)":"var(--naver)";
|
||||
html += `<div class="mall-bar ${i===0?'best':''}">
|
||||
<div class="mname"><span class="sdot" style="background:${color}"></span>${esc(mallLabel(m))}</div>
|
||||
<div class="track"><div class="fill" style="width:${w}%;background:${color}"></div>
|
||||
${i===0?`<div class="badge-best">최저</div>`:``}
|
||||
<div class="mprice">${won(m.price)}원</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// top-N product list
|
||||
const top=o.top||[];
|
||||
if(top.length){
|
||||
html += `<div class="sec-title">같은 상품 최저가 <span class="cnt">상위 ${top.length}개 · 가격은 상품가 기준</span></div>
|
||||
<table><thead><tr><th>#</th><th>소스 · 판매몰</th><th>상품명</th><th>배송</th><th style="text-align:right">상품가</th></tr></thead><tbody>`;
|
||||
top.forEach((p,i)=>{
|
||||
const tot=totalPrice(p);
|
||||
html += `<tr>
|
||||
<td><span class="rank ${i===0?'top':''}">${i+1}</span></td>
|
||||
<td style="white-space:nowrap"><span class="src-tag ${p.source}">${p.source}</span><div class="muted" style="font-size:11px;font-weight:600;margin-top:3px">${esc(mallLabel(p))}</div></td>
|
||||
<td class="name-cell">${p.detail_url?`<a href="${esc(p.detail_url)}" target="_blank" rel="noopener">${esc(p.name)}</a>`:esc(p.name)}</td>
|
||||
<td>${shipBadge(p)}</td>
|
||||
<td class="price-cell" style="text-align:right">${won(p.price)}원${ tot!=null&&p.shipping_fee>0?`<div class="muted" style="font-size:10.5px;font-weight:600">+배송 ${won(tot)}</div>`:``}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
}
|
||||
$("#jobBody").innerHTML=html;
|
||||
}
|
||||
|
||||
/* ---------- price history chart (dependency-free SVG) ---------- */
|
||||
const SERIES=[
|
||||
{key:"naver", label:"네이버", color:"var(--naver)", raw:"#03c75a", dash:false},
|
||||
{key:"coupang", label:"쿠팡", color:"var(--coupang)", raw:"#f0455b", dash:false},
|
||||
{key:"final", label:"최종최저", color:"var(--final)", raw:"#4f46e5", dash:true},
|
||||
];
|
||||
const hidden=new Set();
|
||||
let lastPoints=[];
|
||||
|
||||
$("#loadHistBtn").addEventListener("click",()=> loadHistory($("#histCode").value.trim()));
|
||||
$("#histCode").addEventListener("keydown",e=>{ if(e.key==="Enter") loadHistory(e.target.value.trim()); });
|
||||
|
||||
async function loadHistory(code){
|
||||
if(!code){ toast("상품 코드를 입력하세요","err"); return; }
|
||||
$("#histCode").value=code;
|
||||
$("#chartArea").innerHTML=`<div class="empty" style="padding:30px"><span class="spin-inline"></span> 이력 불러오는 중…</div>`;
|
||||
try{
|
||||
const d = await api(`/v1/lps/products/${encodeURIComponent(code)}/history?limit=200`);
|
||||
lastPoints = d.points||[];
|
||||
renderChart(lastPoints, code);
|
||||
}catch(err){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(err.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderChart(points, code){
|
||||
if(!points.length){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">🗒️</div><b>${esc(code)}</b> 상품의 이력이 아직 없습니다.<div class="muted" style="margin-top:6px;font-size:12px">검색이 완료되면 스냅샷이 쌓입니다.</div></div>`; return; }
|
||||
const W=760,H=300,padL=62,padR=18,padT=18,padB=46;
|
||||
const iw=W-padL-padR, ih=H-padT-padB;
|
||||
const vals=[]; points.forEach(p=>SERIES.forEach(s=>{ if(!hidden.has(s.key)&&p[s.key]!=null) vals.push(p[s.key]); }));
|
||||
let min = vals.length?Math.min(...vals):0, max=vals.length?Math.max(...vals):1;
|
||||
if(min===max){ min=min*0.9; max=max*1.1||1; }
|
||||
const pad=(max-min)*0.12; min=Math.max(0,min-pad); max=max+pad;
|
||||
const n=points.length;
|
||||
const X = i => padL + (n===1? iw/2 : iw*i/(n-1));
|
||||
const Y = v => padT + ih*(1-(v-min)/(max-min));
|
||||
|
||||
// y ticks
|
||||
const ticks=5; let yAxis="";
|
||||
for(let t=0;t<=ticks;t++){ const v=min+(max-min)*t/ticks; const y=Y(v);
|
||||
yAxis+=`<line x1="${padL}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="var(--border-2)" stroke-width="1"/>`;
|
||||
yAxis+=`<text x="${padL-8}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-400)">${won(Math.round(v))}</text>`;
|
||||
}
|
||||
// x labels (sparse)
|
||||
let xAxis=""; const step=Math.ceil(n/6);
|
||||
points.forEach((p,i)=>{ if(i%step!==0 && i!==n-1) return; const x=X(i);
|
||||
const t=(p.triggered_at||"").replace("T"," ").slice(5,16);
|
||||
xAxis+=`<text x="${x}" y="${H-padB+18}" text-anchor="middle" font-size="9.5" fill="var(--text-400)">${esc(t)}</text>`;
|
||||
xAxis+=`<line x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--border-2)" stroke-width="1" stroke-dasharray="2 3" opacity=".5"/>`;
|
||||
});
|
||||
// lines + dots
|
||||
let paths="",dots="";
|
||||
SERIES.forEach(s=>{
|
||||
if(hidden.has(s.key)) return;
|
||||
// build segments skipping nulls
|
||||
let seg=[];
|
||||
const flush=()=>{ if(seg.length>1){ paths+=`<polyline points="${seg.map(pt=>pt.x+","+pt.y).join(" ")}" fill="none" stroke="${s.raw}" stroke-width="${s.dash?2.5:2}" ${s.dash?'stroke-dasharray="6 4"':''} stroke-linejoin="round" stroke-linecap="round"/>`; } seg=[]; };
|
||||
points.forEach((p,i)=>{ const v=p[s.key]; if(v==null){ flush(); return; } const x=X(i),y=Y(v); seg.push({x,y});
|
||||
dots+=`<circle cx="${x}" cy="${y}" r="3" fill="#fff" stroke="${s.raw}" stroke-width="2"/>`; });
|
||||
flush();
|
||||
});
|
||||
// hover columns
|
||||
let hover="";
|
||||
points.forEach((p,i)=>{ const x=X(i);
|
||||
hover+=`<rect x="${x-(iw/n/2||14)}" y="${padT}" width="${iw/n||28}" height="${ih}" fill="transparent" data-i="${i}" class="hovcol"/>`;
|
||||
hover+=`<line class="hovline" data-i="${i}" x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--primary-500)" stroke-width="1" opacity="0"/>`;
|
||||
});
|
||||
|
||||
$("#chartArea").innerHTML=`
|
||||
<div class="chart-wrap">
|
||||
<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
||||
${yAxis}${xAxis}${paths}${dots}${hover}
|
||||
</svg>
|
||||
<div class="chart-tip" id="chartTip"></div>
|
||||
</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="muted" style="font-size:11.5px;margin:12px 0 0">스냅샷 ${n}건 · 점선(최종최저)이 네이버·쿠팡 중 낮은 값을 따라갑니다. 값이 빈 구간은 그 소스에 상품이 없던 시점입니다.</p>
|
||||
`;
|
||||
// legend
|
||||
const lg=$("#legend");
|
||||
SERIES.forEach(s=>{
|
||||
const el=document.createElement("div"); el.className="legend-item"+(hidden.has(s.key)?" off":"");
|
||||
el.innerHTML=`<span class="swatch ${s.dash?'dashed':''}" style="${s.dash?`border-color:${s.raw}`:`background:${s.raw}`}"></span>${s.label}`;
|
||||
el.addEventListener("click",()=>{ if(hidden.has(s.key))hidden.delete(s.key); else hidden.add(s.key); renderChart(lastPoints,code); });
|
||||
lg.appendChild(el);
|
||||
});
|
||||
// hover interaction
|
||||
const tip=$("#chartTip"), svg=$(".chart");
|
||||
$$(".hovcol").forEach(col=>{
|
||||
col.style.cursor="crosshair";
|
||||
col.addEventListener("mouseenter",()=>{
|
||||
const i=+col.dataset.i, p=points[i];
|
||||
$$(".hovline").forEach(l=>l.setAttribute("opacity", (+l.dataset.i===i)?".5":"0"));
|
||||
let rows="";
|
||||
SERIES.forEach(s=>{ if(hidden.has(s.key)) return; const v=p[s.key];
|
||||
rows+=`<div class="tt-row"><span class="k"><span class="tt-dot" style="background:${s.raw}"></span>${s.label}</span><span>${v==null?'—':won(v)+'원'}</span></div>`; });
|
||||
tip.innerHTML=`<div class="tt-time">${esc((p.triggered_at||'').replace('T',' ').slice(0,19))} · ${esc(p.outcome||'')}</div>${rows}`;
|
||||
// position
|
||||
const bb=svg.getBoundingClientRect(); const scale=bb.width/W; const px=(X(i))*scale; const py=padT*scale+8;
|
||||
tip.style.left=px+"px"; tip.style.top=py+"px"; tip.style.opacity="1";
|
||||
});
|
||||
col.addEventListener("mouseleave",()=>{ tip.style.opacity="0"; $$(".hovline").forEach(l=>l.setAttribute("opacity","0")); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
27
lps-temp-fe/serve.sh
Executable file
27
lps-temp-fe/serve.sh
Executable file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LPS 임시 프론트엔드 서버 (대화형).
|
||||
# CORS 때문에 반드시 :5173 에서 서빙해야 lps API(:9600) 호출이 통과한다.
|
||||
# (lps/config.local.toml 의 cors_origins = http://localhost:5173)
|
||||
#
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PORT=5173
|
||||
echo "── LPS 임시 FE 서버 ──"
|
||||
echo " API(:9600) 는 별도로 실행돼 있어야 합니다 (lps/run_local_server.sh + worker_main.py)."
|
||||
echo ""
|
||||
read -rp "포트 [${PORT}]: " p
|
||||
PORT="${p:-$PORT}"
|
||||
|
||||
# 포트 정리
|
||||
if lsof -ti:"$PORT" >/dev/null 2>&1; then
|
||||
echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료"
|
||||
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "[run] http://localhost:${PORT} (Ctrl+C 로 종료)"
|
||||
# 브라우저 자동 오픈 (mac)
|
||||
( sleep 1; command -v open >/dev/null && open "http://localhost:${PORT}" ) &
|
||||
exec python3 -m http.server "$PORT" --bind 127.0.0.1
|
||||
Loading…
Reference in New Issue
Block a user