o2o-negosium-original/lps-admin/src/pages/TestSearch.tsx
민헌 d111610555 feat(lps-admin): 관리자 페이지 디자인 개선 및 컴포넌트 통일
- 공용 프리미티브(ui.tsx): PageHeader·Segmented·Button·SearchForm·Legend·
  ScrollBox·ScrollTable·Modal + Card(fill/compact) 옵션
- 차트 공용 설정(lib/chart.ts): 축·그리드·툴팁 스타일 단일화
- 뷰포트 채우기 레이아웃 + content 영역 스크롤(Layout), 버튼 커서 복구
- 표: 헤더 밴드·행 좌우 패딩·고정/채움/auto 높이·sticky 헤더, 배지 줄바꿈 방지
- 작업 큐: 풀폭 표 + 행별 상세/JSON 모달(복사)
- 상품·가격: 가로 배치, Y축 0 기준, 시점 클릭→몰별 비교, 커스텀 툴팁·
  그라데이션 area, 몰별 컴팩트 표(소스 뱃지)
- 크롤 상태: 도넛 중앙 총계·라운드 세그먼트, 막대 그라데이션, 툴팁 통일
- 테스트 검색: 결과 통일(모달·표·소스 뱃지), 진행상태 좌측 이동, 초기화 전
  재검색 차단, 퍼널 가독성(감소량), 배송(로켓배송) 표기, 한 화면 컴팩트
- 사이드바: API 상태 상단 뱃지, 테스트 메뉴 배경색으로 구분

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:48:43 +09:00

427 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→AI 매칭)에 흘려보내고
* 단계별 퍼널·소스별 결과·매칭 상품·검색 원가를 시각화한다. 셀렉터·프록시·매칭 회귀 점검용.
*
* product_code 는 TEST-<시각> 으로 자동 생성 — 프로덕션 데이터(잡·이력·비용통계)와 필터로
* 구분되고, negodata 는 UUID 만 매핑하므로 연동에 영향 없다. 자격증명은 서버에만 있으며
* 여기 결과엔 집계된 프록시 사용량(바이트·$)만 표시된다. */
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { get, post } from "../api/client";
import type { JobDetailRes, JobOutput, SearchRes } from "../api/types";
import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui";
import { usd, won } from "../lib/format";
const TOP_COLS = [
{ key: "source", label: "소스" },
{ key: "name", label: "상품명" },
{ key: "price", label: "가격", align: "right" },
{ key: "ship", label: "배송", align: "right" },
{ key: "link", label: "" },
] as const;
const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
// 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어.
const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
// 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상.
function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } {
const t = (type ?? "").toLowerCase();
if (t.startsWith("rocket")) {
const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우"
: t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송";
return { text: label, tone: "rocket" };
}
if (t === "free" || fee === 0) return { text: "무료", tone: "free" };
if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" };
return { text: "—", tone: "none" };
}
// 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만.
const sourceMall = (source: string, mall?: string) => {
const sl = sourceLabel(source);
return mall && mall !== sl ? `${sl} · ${mall}` : sl;
};
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
const STAGE_LABEL: Record<string, string> = {
filter: "가격/몰 필터",
outlier: "이상치 제거",
ai_match: "AI 같은상품 판정",
top_n: "최저가 top-N",
};
function makeTestCode() {
// Date.now 로 사람이 읽을 수 있는 시각 코드(중복 방지 + 목록 필터 구분).
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `TEST-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
interface FormState {
product_name: string;
model: string;
specification: string;
company: string;
price: string;
}
export default function TestSearch() {
const [form, setForm] = useState<FormState>({ product_name: "", model: "", specification: "", company: "", price: "" });
const [jobId, setJobId] = useState<string | null>(null);
const [testCode, setTestCode] = useState<string | null>(null);
const startedAt = useRef<number>(0);
const submit = useMutation({
mutationFn: async () => {
const code = makeTestCode();
const res = await post<SearchRes>("/v1/lps/search", {
data: [{
product_code: code,
product_name: form.product_name.trim(),
job_type: "manual",
model: form.model.trim(),
specification: form.specification.trim(),
company: form.company.trim(),
price: form.price.trim(),
}],
});
return { res, code };
},
onSuccess: ({ res, code }) => {
const entry = res.items[0];
setTestCode(code);
startedAt.current = Date.now();
setJobId(entry?.job_id ?? null);
},
});
// 잡 완료까지 2초 폴링. DONE/DEAD 면 멈춘다.
const job = useQuery({
queryKey: ["test-job", jobId],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${jobId}`),
enabled: !!jobId,
refetchInterval: (q) => {
const st = q.state.data?.status;
return st === "DONE" || st === "DEAD" ? false : 2000;
},
});
const status = job.data?.status;
const output = job.data?.output;
const done = status === "DONE" || status === "DEAD";
// 진행 중엔 1초마다 경과 시간을 갱신(폴링 간격과 무관하게 매끄럽게), 완료되면 정지.
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!jobId || done) return;
const tick = () => setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [jobId, done]);
const pendingTooLong = status === "PENDING" && elapsed > 8;
const reset = () => {
setJobId(null);
setTestCode(null);
submit.reset();
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<PageHeader title="테스트 검색" />
<p className="text-[11px] text-ink-400">
실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
</p>
</div>
<div className="grid gap-3 lg:grid-cols-[360px_1fr]">
{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
<div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
<Card compact title="검색 조건">
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
>
<Field id="pn" label="상품명" required value={form.product_name}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<div className="grid grid-cols-2 gap-3">
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
</div>
<Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<div className="flex gap-2 pt-1">
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
{submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
</button>
{jobId && (
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
)}
</div>
{submit.isError && <ErrorNote error={submit.error} />}
</form>
</Card>
{jobId && (
<Card compact title="진행 상태" hint={testCode ?? undefined}>
<Progress status={status} elapsed={elapsed} />
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
{pendingTooLong && (
<p role="alert" className="mt-3 rounded-lg bg-warn-50 px-3 py-2 text-[12px] font-medium text-warn-700">
{elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (<code>./run_local_worker.sh</code>). 워커가 없으면 크롤이 시작되지 않습니다.
</p>
)}
{status === "DEAD" && (
<p role="alert" className="mt-3 rounded-lg bg-dead-50 px-3 py-2 text-[12px] font-medium text-dead-600">
검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}
</p>
)}
</Card>
)}
</div>
{/* 오른쪽: 결과 */}
<div className="space-y-3">
{status === "DONE" && output ? (
<ResultView output={output} />
) : (
<Card compact title="결과">
<Empty>
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
</Empty>
</Card>
)}
</div>
</div>
</div>
);
}
// ── 진행 스텝퍼 ──────────────────────────────────────────────
function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
const steps = [
{ key: "PENDING", label: "대기" },
{ key: "RUNNING", label: "크롤·판정 중" },
{ key: "DONE", label: "완료" },
];
const order = ["PENDING", "RUNNING", "DONE"];
const curIdx = status === "DEAD" ? 1 : Math.max(0, order.indexOf(status ?? "PENDING"));
return (
<div className="flex items-center gap-2">
{steps.map((s, i) => {
const done = i < curIdx || status === "DONE";
const active = i === curIdx && status !== "DONE";
return (
<div key={s.key} className="flex items-center gap-2">
<span className={`flex h-6 items-center rounded-full px-2.5 text-[12px] font-semibold ${
done ? "bg-ok-50 text-ok-600" : active ? "bg-run-50 text-run-600" : "bg-surface-2 text-ink-400"
}`}>
{active && <span className="mr-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-run-600" aria-hidden />}
{s.label}
</span>
{i < steps.length - 1 && <span className="h-px w-4 bg-line-200" aria-hidden />}
</div>
);
})}
<span className="ml-auto tnum text-[12px] text-ink-400">{elapsed}초</span>
</div>
);
}
// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ──────
function ResultView({ output }: { output: JobOutput }) {
const found = output.outcome === "found";
const m = output.metrics;
const [showJson, setShowJson] = useState(false);
const [copied, setCopied] = useState(false);
const jsonText = JSON.stringify(output, null, 2);
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return (
<>
<Card compact className={found ? "border-l-[3px] border-l-primary-500" : ""}
title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
{found ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span>
{output.lowest && (
<span className="text-[15px] font-bold">
최저가 {won(output.lowest.price)}
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
({sourceMall(output.lowest.source, output.lowest.mall_name)})
</span>
</span>
)}
</div>
) : (
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[12px] font-semibold text-ink-500">
같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""}
</span>
)}
{output.query && <p className="mt-2 text-[12px] text-ink-500">최종 검색어: <b>{output.query}</b></p>}
</Card>
{output.stages && output.stages.length > 0 && (
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<FunnelChart stages={output.stages} totalFound={output.total_found} />
</Card>
)}
<div className="grid gap-3 md:grid-cols-2">
{output.sources && Object.keys(output.sources).length > 0 && (
<Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
{output.query && (
<p className="mb-2 rounded-md bg-surface-2 px-2 py-1 text-[11px] text-ink-500">
각 사이트 검색어 <span className="font-semibold text-ink-700">"{output.query}"</span>
</p>
)}
<ul className="space-y-1 text-[12px]">
{Object.entries(output.sources).map(([s, v]) => (
<li key={s} className="flex items-baseline justify-between gap-2">
<span className="font-semibold">{sourceLabel(s)}</span>
<span className="tnum text-right">
{v.error ? (
<span className="font-semibold text-dead-600">실패 · {v.error.split(":")[0]}</span>
) : (
<>{v.count ?? 0}건</>
)}
{m?.source_ms?.[s] != null && <span className="ml-1 text-ink-400">· {(m.source_ms[s] / 1000).toFixed(1)}초</span>}
</span>
</li>
))}
</ul>
</Card>
)}
{m && (
<Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1 text-[12px]">
<li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
{m.crawl && (
<li className="flex justify-between border-t border-line-100 pt-1.5">
<span className="text-ink-500">프록시 전송량</span>
<span className="tnum text-ink-400">{(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회</span>
</li>
)}
</ul>
</Card>
)}
</div>
{output.top && output.top.length > 0 && (
<Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
<ScrollTable height="auto" dense columns={TOP_COLS}>
{output.top.map((p, i) => {
const src = srcColor(p.source);
const ship = shipInfo(p.shipping_fee, p.shipping_type);
return (
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-1.5 pr-2 align-top">
<span className="inline-block whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{sourceLabel(p.source)}</span>
</td>
<td className="max-w-md truncate py-1.5 pr-2 align-top">{p.name}</td>
<td className="py-1.5 pr-2 text-right align-top whitespace-nowrap">
<span className={`tnum font-bold ${i === 0 ? "text-ok-600" : ""}`}>{won(p.price)}</span>
{i === 0 && <span className="ml-1 whitespace-nowrap rounded bg-ok-50 px-1 text-[10px] font-bold text-ok-600">최저</span>}
</td>
<td className="py-1.5 pr-2 text-right align-top">
{ship.tone === "rocket" ? (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold text-run-600"
style={{ background: "color-mix(in srgb, var(--color-run-600) 12%, transparent)" }}>🚀 {ship.text}</span>
) : (
<span className={`tnum whitespace-nowrap text-[11px] ${ship.tone === "free" ? "font-semibold text-ok-600" : "text-ink-400"}`}>{ship.text}</span>
)}
</td>
<td className="py-1.5 text-right align-top">
{p.detail_url && (
<a href={p.detail_url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-line-200 px-2 py-0.5 text-[11px] font-semibold text-ink-700 transition-colors hover:bg-line-200 hover:text-ink-900">사이트 →</a>
)}
</td>
</tr>
);
})}
</ScrollTable>
</Card>
)}
<div>
<Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
</div>
{showJson && (
<Modal title="원본 결과 (JSON)" onClose={() => setShowJson(false)}
actions={<Button variant="outline" size="sm" onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>}>
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
</Modal>
)}
</>
);
}
// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ──────────
function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: number; out: number }[]; totalFound?: number }) {
const rows = [
...(totalFound != null ? [{ label: "수집", value: totalFound }] : []),
...stages.map((s) => ({ label: STAGE_LABEL[s.stage] ?? s.stage, value: s.out })),
];
const max = Math.max(1, ...rows.map((r) => r.value));
return (
<ul className="space-y-1">
{rows.map((r, i) => {
const prev = i > 0 ? rows[i - 1].value : null;
const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
return (
// 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
<li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
<span className="truncate text-ink-500">{r.label}</span>
<div className="h-4 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-gradient-to-r from-primary-400 to-primary-600"
style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "3px" : 0 }} aria-hidden />
</div>
<span className="tnum text-right font-semibold">{r.value}</span>
{/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */}
<span className={`tnum whitespace-nowrap text-right text-[11px] ${drop != null && drop < 0 ? "font-semibold text-dead-600" : "text-ink-400"}`}>
{drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
</span>
</li>
);
})}
</ul>
);
}
// ── 폼 필드(라벨-인풋 연결, Settings 패턴 준수) ──────────────
function Field({ id, label, value, onChange, placeholder, required, inputMode }: {
id: string; label: string; value: string; onChange: (v: string) => void;
placeholder?: string; required?: boolean; inputMode?: "numeric";
}) {
return (
<div>
<label htmlFor={id} className="mb-1 block text-[12px] font-semibold text-ink-700">
{label}{required && <span className="ml-0.5 text-dead-600">*</span>}
</label>
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
inputMode={inputMode} autoComplete="off"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
</div>
);
}