From d1116105553c677a5f873fa016041d6caf29facc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 16 Jul 2026 08:48:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps-admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 프리미티브(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) --- lps-admin/src/components/Layout.tsx | 35 ++-- lps-admin/src/components/ui.tsx | 162 ++++++++++++++++- lps-admin/src/index.css | 10 ++ lps-admin/src/lib/chart.ts | 35 ++++ lps-admin/src/pages/Costs.tsx | 95 +++++----- lps-admin/src/pages/Crawl.tsx | 224 +++++++++++------------ lps-admin/src/pages/Dashboard.tsx | 51 +++--- lps-admin/src/pages/Jobs.tsx | 239 +++++++++++++------------ lps-admin/src/pages/Products.tsx | 267 +++++++++++++++++----------- lps-admin/src/pages/Settings.tsx | 8 +- lps-admin/src/pages/TestSearch.tsx | 259 +++++++++++++++++---------- 11 files changed, 860 insertions(+), 525 deletions(-) create mode 100644 lps-admin/src/lib/chart.ts diff --git a/lps-admin/src/components/Layout.tsx b/lps-admin/src/components/Layout.tsx index 83b13b2..a81e469 100644 --- a/lps-admin/src/components/Layout.tsx +++ b/lps-admin/src/components/Layout.tsx @@ -4,15 +4,14 @@ import { useQuery } from "@tanstack/react-query"; import { NavLink, Outlet } from "react-router"; import { healthz } from "../api/client"; -// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 앰버(warn) 마커로 구분. -// group: 이 항목 앞에 구분선(모니터링 ↔ 실행/설정 경계). +// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게. const MENU = [ { to: "/", label: "대시보드", end: true }, { to: "/jobs", label: "작업 큐" }, { to: "/products", label: "상품·가격" }, { to: "/crawl", label: "크롤 상태" }, { to: "/costs", label: "비용" }, - { to: "/test", label: "테스트 검색", action: true, group: true }, + { to: "/test", label: "테스트 검색", action: true }, { to: "/settings", label: "설정" }, ]; @@ -20,55 +19,49 @@ export default function Layout() { const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 }); const up = health.data === true; return ( -
+
{/* 모바일(
-
+
L
LPS 관리자
인터넷 최저가 검색
-
- - {up ? "연결됨" : "연결 안 됨"} + {/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */} +
+ + + {up ? "API 연결됨" : "API 연결 안 됨"} +
-
- - {up ? "API 연결됨" : "API 연결 안 됨"} -
-
+ {/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */} +
diff --git a/lps-admin/src/components/ui.tsx b/lps-admin/src/components/ui.tsx index bc25935..5f2825d 100644 --- a/lps-admin/src/components/ui.tsx +++ b/lps-admin/src/components/ui.tsx @@ -1,20 +1,24 @@ -/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시. 웹 표준 시맨틱 마크업 유지. */ +/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시 + 페이지 공통 컨트롤 + * (헤더·세그먼트 탭·버튼·검색폼·범례·고정높이 표/스크롤). 웹 표준 시맨틱 마크업 유지. */ -import type { ReactNode } from "react"; +import { useEffect } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { JobStatusName } from "../api/types"; -export function Card({ title, hint, children, className = "" }: { - title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; +// fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용). +// compact: 패딩을 줄여 밀도 높은 레이아웃(한 화면에 많이 담기). +export function Card({ title, hint, children, className = "", fill = false, compact = false }: { + title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; fill?: boolean; compact?: boolean; }) { return ( -
+
{title !== undefined && ( -
+

{title}

- {hint && {hint}} + {hint &&
{hint}
}
)} -
{children}
+
{children}
); } @@ -71,3 +75,145 @@ export function ErrorNote({ error }: { error: unknown }) { export function Loading() { return

불러오는 중…

; } + +// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ── +export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) { + return ( +
+

{title}

+ {children &&
{children}
} +
+ ); +} + +// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ── +export function Segmented({ options, value, onChange, ariaLabel, size = "md" }: { + options: readonly { v: T; label: ReactNode }[]; + value: T; onChange: (v: T) => void; ariaLabel: string; size?: "sm" | "md"; +}) { + const cell = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-3 py-1.5 text-[12px]"; + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ── +export function Button({ variant = "primary", size = "md", className = "", ...props }: { + variant?: "primary" | "outline"; size?: "sm" | "md" | "lg"; +} & ButtonHTMLAttributes) { + const sizes = { sm: "px-2.5 py-1 text-[12px]", md: "px-3 py-1.5 text-[13px]", lg: "px-4 py-2 text-[13px]" }; + const variants = { + primary: "bg-primary-600 text-white hover:bg-primary-700", + outline: "border border-line-200 text-ink-700 hover:bg-line-200 hover:text-ink-900", + }; + return + + ); +} + +// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ── +export function Legend({ items, note }: { + items: readonly { label: ReactNode; color: string }[]; note?: ReactNode; +}) { + return ( +

+ {items.map((it, i) => ( + + + {it.label} + + ))} + {note && {note}} +

+ ); +} + +// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ── +export function ScrollBox({ height = 420, className = "", children }: { + height?: number | "fill"; className?: string; children: ReactNode; +}) { + const fill = height === "fill"; + return ( +
{children}
+ ); +} + +// ── 모달 — 배경 딤 + Esc/바깥클릭 닫기. actions 로 헤더 우측 버튼(복사 등) 배치. ── +export function Modal({ title, onClose, actions, children }: { + title: ReactNode; onClose: () => void; actions?: ReactNode; children: ReactNode; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + return ( +
+
+
+
+

{title}

+
+ {actions} + +
+
+
{children}
+
+
+ ); +} + +// ── 표 — thead sticky. height 숫자=고정 높이, "fill"=남는 높이 채움, "auto"=내용 높이. +// fixed=table-fixed(좁은 폭에서 열 넘침·줄바꿈 방지), 열별 width 지정 가능. ── +export function ScrollTable({ height = 420, dense = false, fixed = false, columns, children }: { + height?: number | "fill" | "auto"; dense?: boolean; fixed?: boolean; + columns: readonly { key: string; label: ReactNode; align?: "right"; width?: string }[]; + children: ReactNode; +}) { + const pad = dense ? "pb-1.5 pr-2 pt-1.5" : "pb-2 pr-3 pt-2"; + const fill = height === "fill"; + const auto = height === "auto"; + // 행 좌우 여백: 첫/마지막 셀에 padding(헤더·본문 공통). 헤더는 bg-surface-2 밴드로 카드 제목바와 구분. + const edge = dense ? "[&_th:first-child]:pl-3 [&_td:first-child]:pl-3 [&_th:last-child]:pr-3 [&_td:last-child]:pr-3" + : "[&_th:first-child]:pl-4 [&_td:first-child]:pl-4 [&_th:last-child]:pr-4 [&_td:last-child]:pr-4"; + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + {children} +
+ {c.label} +
+
+ ); +} diff --git a/lps-admin/src/index.css b/lps-admin/src/index.css index 8b45707..2261085 100644 --- a/lps-admin/src/index.css +++ b/lps-admin/src/index.css @@ -49,10 +49,20 @@ "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; } +/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */ +html, body, #root { + height: 100%; +} + body { @apply bg-page text-ink-900 font-sans text-[14px] antialiased; } +/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */ +button:not(:disabled) { + cursor: pointer; +} + /* 테이블 숫자 열 정렬 */ .tnum { font-variant-numeric: tabular-nums; diff --git a/lps-admin/src/lib/chart.ts b/lps-admin/src/lib/chart.ts new file mode 100644 index 0000000..1d52cd5 --- /dev/null +++ b/lps-admin/src/lib/chart.ts @@ -0,0 +1,35 @@ +/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화. + * 색은 index.css @theme 토큰 참조(단일 소스). 사용: */ + +export const gridProps = { stroke: "var(--color-grid)", vertical: false } as const; + +// X축: 하단 눈금 + 옅은 축선(기준선 역할). +export const xAxisProps = { + tick: { fill: "var(--color-ink-400)" }, + tickLine: false, + axisLine: { stroke: "var(--color-line-200)" }, +} as const; + +// Y축: 눈금만(축선 없음 — 그리드가 대신함). +export const yAxisProps = { + tick: { fill: "var(--color-ink-400)" }, + tickLine: false, + axisLine: false, +} as const; + +// 툴팁 — 둥근 카드·부드러운 그림자·토큰 색(기본 recharts 툴팁을 앱 톤으로 통일). +// 사용: (막대엔 cursor={barCursor} 추가) +export const tooltipProps = { + contentStyle: { + borderRadius: 12, + border: "1px solid var(--color-line-200)", + background: "var(--color-surface)", + boxShadow: "0 8px 24px rgba(16,24,40,.14)", + padding: "8px 12px", + }, + labelStyle: { color: "var(--color-ink-500)", fontWeight: 700, marginBottom: 4, fontSize: 12 }, + itemStyle: { padding: "1px 0", fontSize: 12 }, +} as const; + +// 막대 hover 커서 — 옅은 배경 하이라이트(기본 회색 박스 대신). +export const barCursor = { fill: "color-mix(in srgb, var(--color-ink-900) 4%, transparent)" } as const; diff --git a/lps-admin/src/pages/Costs.tsx b/lps-admin/src/pages/Costs.tsx index faa29b8..22ecf7e 100644 --- a/lps-admin/src/pages/Costs.tsx +++ b/lps-admin/src/pages/Costs.tsx @@ -7,13 +7,14 @@ import { } from "recharts"; import { get } from "../api/client"; import type { CostStatsRes } from "../api/types"; -import { Card, Empty, ErrorNote, Loading, StatTile } from "../components/ui"; +import { Card, Empty, ErrorNote, Legend, Loading, PageHeader, Segmented, StatTile } from "../components/ui"; +import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart"; import { dateShort, usd } from "../lib/format"; const RANGES = [ - { h: 24, label: "24시간" }, - { h: 48, label: "48시간" }, - { h: 168, label: "7일" }, + { v: 24, label: "24시간" }, + { v: 48, label: "48시간" }, + { v: 168, label: "7일" }, ]; export default function Costs() { @@ -32,68 +33,66 @@ export default function Costs() { const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null; return ( -
-
-

비용

-
- {RANGES.map((r) => ( - - ))} -
-
+
+ + + {cost.isError && } -
+
0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
- + {cost.isPending && } - {cost.data && buckets.length === 0 && 기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다} + {cost.data && buckets.length === 0 && ( +
기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다
+ )} {buckets.length > 0 && ( <> - - - - - `$${v}`} /> - [usd(v), name]} - labelFormatter={(l, payload) => { - const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs; - return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`; - }} /> - - - - -

- AI (판정·검색어) - 프록시 대역폭 (DECODO) -

+
+ + + + + `$${v}`} /> + [usd(v), name]} + labelFormatter={(l, payload) => { + const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs; + return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`; + }} /> + + + + +
+ )}
- + {buckets.length === 0 ? ( - 데이터 없음 +
데이터 없음
) : ( - - - - - `${(v / 1000).toFixed(0)}s`} /> - [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} /> - - - +
+ + + + + `${(v / 1000).toFixed(0)}s`} /> + [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} /> + + + +
)}
diff --git a/lps-admin/src/pages/Crawl.tsx b/lps-admin/src/pages/Crawl.tsx index 64036e7..6d1bbf5 100644 --- a/lps-admin/src/pages/Crawl.tsx +++ b/lps-admin/src/pages/Crawl.tsx @@ -7,9 +7,28 @@ import { } from "recharts"; import { get } from "../api/client"; import type { BotStatsRes, IpSessionStatsRes } from "../api/types"; -import { Card, Empty, ErrorNote, Loading } from "../components/ui"; +import { Card, Empty, ErrorNote, Loading, PageHeader, ScrollTable, Segmented } from "../components/ui"; +import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart"; import { dateShort, durationSec, hhmm } from "../lib/format"; +const BOT_COLS = [ + { key: "time", label: "시각" }, + { key: "query", label: "검색어" }, + { key: "reqno", label: "요청#", align: "right" }, + { key: "port", label: "포트", align: "right" }, + { key: "marker", label: "감지 근거" }, +] as const; + +const SESSION_COLS = [ + { key: "end", label: "종료 시각" }, + { key: "source", label: "소스" }, + { key: "port", label: "포트", align: "right" }, + { key: "req", label: "요청", align: "right" }, + { key: "okblock", label: "성공/차단", align: "right" }, + { key: "dur", label: "지속", align: "right" }, + { key: "reason", label: "종료 사유" }, +] as const; + // 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) — // raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다). const REASONS: Record = { @@ -23,9 +42,9 @@ const REASONS: Record = }; const RANGES = [ - { h: 24, label: "24시간" }, - { h: 168, label: "7일" }, - { h: 720, label: "30일" }, + { v: 24, label: "24시간" }, + { v: 168, label: "7일" }, + { v: 720, label: "30일" }, ]; export default function Crawl() { @@ -49,17 +68,9 @@ export default function Crawl() { return (
-
-

크롤 상태

-
- {RANGES.map((r) => ( - - ))} -
-
+ + + {ip.isError && } @@ -82,14 +93,22 @@ export default function Crawl() { {ip.data && reasons.length === 0 && 기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다} {reasons.length > 0 && (
- {/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원) */} - - - {reasons.map((r) => )} - - [`${v}건`, name]} /> - + {/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */} +
+ + + {reasons.map((r) => )} + + {/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */} + +
+
+
{totalSessions}
+
세션
+
+
+
    {reasons.map((r) => (
  • @@ -108,25 +127,32 @@ export default function Crawl() { )} - + {ip.isPending && } - {ip.data && ip.data.histogram.length === 0 && 데이터 없음} + {ip.data && ip.data.histogram.length === 0 &&
    데이터 없음
    } {ip.data && ip.data.histogram.length > 0 && ( <> - - - - - - [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} /> - - {ip.data.block_min_requests != null && ( - - )} - - +
    + + + + + + + + + + + + [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} /> + + {ip.data.block_min_requests != null && ( + + )} + + +

    X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.

    @@ -136,50 +162,45 @@ export default function Crawl() {
- + {bot.isPending && } {bot.isError && } - {bot.data && bot.data.hourly.length === 0 && 기간 내 차단 없음 🎉} + {bot.data && bot.data.hourly.length === 0 &&
기간 내 차단 없음 🎉
} {bot.data && bot.data.hourly.length > 0 && ( - - ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}> - - - - [`${v}건`, "차단"]} /> - - - +
+ + ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}> + + + + + + + + + + [`${v}건`, "차단"]} /> + + + +
)}
{bot.data && bot.data.items.length === 0 && 기간 내 차단 없음} {bot.data && bot.data.items.length > 0 && ( -
- - - - - - - - - - - - {bot.data.items.map((b, i) => ( - - - - - - - - ))} - -
시각검색어요청#포트감지 근거
{hhmm(b.created_at)}{b.query || "—"}{b.ip_request_no ?? "—"}{b.proxy_port ?? "—"}{b.marker}
-
+ + {bot.data.items.map((b, i) => ( + + {hhmm(b.created_at)} + {b.query || "—"} + {b.ip_request_no ?? "—"} + {b.proxy_port ?? "—"} + {b.marker} + + ))} + )}
@@ -187,42 +208,27 @@ export default function Crawl() { {ip.data && ip.data.sessions.length === 0 && 세션 없음} {ip.data && ip.data.sessions.length > 0 && ( -
- - - - - - - - - - + + {ip.data.sessions.map((s, i) => { + const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" }; + return ( + + + + + + + + - - - {ip.data.sessions.map((s, i) => { - const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" }; - return ( - - - - - - - - - - ); - })} - -
종료 시각소스포트요청성공/차단지속종료 사유
{dateShort(s.created_at)}{s.source}{s.proxy_port ?? "—"}{s.requests}{s.ok_count}/{s.blocked_count}{durationSec(s.elapsed_sec)} + + + {r.label} + +
{dateShort(s.created_at)}{s.source}{s.proxy_port ?? "—"}{s.requests}{s.ok_count}/{s.blocked_count}{durationSec(s.elapsed_sec)} - - - {r.label} - -
-
+ ); + })} + )}
diff --git a/lps-admin/src/pages/Dashboard.tsx b/lps-admin/src/pages/Dashboard.tsx index 2c1c279..cf090fd 100644 --- a/lps-admin/src/pages/Dashboard.tsx +++ b/lps-admin/src/pages/Dashboard.tsx @@ -7,7 +7,8 @@ import { } from "recharts"; import { get } from "../api/client"; import type { OpsSnapshot } from "../api/types"; -import { Card, ErrorNote, StatTile } from "../components/ui"; +import { Card, ErrorNote, Legend, PageHeader, StatTile } from "../components/ui"; +import { gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart"; import { durationSec, usd } from "../lib/format"; // 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너). @@ -44,8 +45,8 @@ export default function Dashboard() { } return ( -
-

대시보드

+
+ {ops.isError && } {alerts.map((a) => ( @@ -71,28 +72,32 @@ export default function Dashboard() {
- - {trend.current.length < 2 ? ( -

수집 중… 잠시 후 그래프가 나타납니다

- ) : ( - - - - - - [v, name === "pending" ? "대기" : "처리중"]} /> - - - - - )} -

- 대기 - 처리중 -

+ +
+ {trend.current.length < 2 ? ( +
+

수집 중… 잠시 후 그래프가 나타납니다

+
+ ) : ( + + + + + + [v, name === "pending" ? "대기" : "처리중"]} /> + + + + + )} +
+
-

+

임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음). 전체 룰은 lps/docs/operations.md 참고.

diff --git a/lps-admin/src/pages/Jobs.tsx b/lps-admin/src/pages/Jobs.tsx index b4f2aa4..a52d63e 100644 --- a/lps-admin/src/pages/Jobs.tsx +++ b/lps-admin/src/pages/Jobs.tsx @@ -1,12 +1,24 @@ -/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) + 상세 패널 + DEAD 재큐. */ +/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { get, post } from "../api/client"; import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types"; -import { Card, Empty, ErrorNote, Loading, StatusBadge } from "../components/ui"; +import { Button, Card, Empty, ErrorNote, Loading, Modal, PageHeader, ScrollTable, SearchForm, Segmented, StatusBadge } from "../components/ui"; import { dateShort, usd, won } from "../lib/format"; +type DetailView = "detail" | "json"; + +const COLS = [ + { key: "product", label: "상품" }, + { key: "status", label: "상태" }, + { key: "outcome", label: "결과" }, + { key: "price", label: "최저가", align: "right" }, + { key: "cost", label: "원가", align: "right" }, + { key: "time", label: "요청 시각" }, + { key: "actions", label: "", align: "right" }, +] as const; + const PAGE = 20; const TABS: { v: JobStatusName | ""; label: string }[] = [ { v: "", label: "전체" }, @@ -21,7 +33,10 @@ export default function Jobs() { const [q, setQ] = useState(""); const [qInput, setQInput] = useState(""); const [page, setPage] = useState(0); - const [selected, setSelected] = useState(null); + const [modalJob, setModalJob] = useState(null); + const [modalView, setModalView] = useState("detail"); + + const open = (j: JobListItem, view: DetailView) => { setModalView(view); setModalJob(j); }; const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) }); if (status) params.set("status", status); @@ -35,21 +50,12 @@ export default function Jobs() { const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE)); return ( -
-

작업 큐

+
+
-
- {TABS.map((t) => ( - - ))} -
+ { setStatus(v); setPage(0); }} /> -
{ e.preventDefault(); setQ(qInput.trim()); setPage(0); }}> - setQInput(e.target.value)} placeholder="상품코드·상품명 검색" aria-label="상품코드·상품명 검색" - className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" /> - -
+ { setQ(qInput.trim()); setPage(0); }} />
-
- - {list.isPending && } - {list.isError && } - {list.data && list.data.items.length === 0 && 조건에 맞는 작업이 없습니다} - {list.data && list.data.items.length > 0 && ( -
- - - - - - - - - - - - - {list.data.items.map((j) => ( - setSelected(j)} tabIndex={0} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(j); } }} - aria-selected={selected?.job_id === j.job_id} - className={`cursor-pointer border-b border-line-100 hover:bg-surface-2 focus-visible:bg-primary-50/60 ${selected?.job_id === j.job_id ? "bg-primary-50/60" : ""}`}> - - - - - - - - ))} - -
상품상태결과최저가원가요청 시각
-
{j.product_name || "—"}
-
{j.product_code}
-
{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}{won(j.final_lowest)}{usd(j.cost_usd)}{dateShort(j.created_at)}
-
- )} - {pages > 1 && ( - - )} -
+ + {list.isPending && } + {list.isError && } + {list.data && list.data.items.length === 0 && 조건에 맞는 작업이 없습니다} + {list.data && list.data.items.length > 0 && ( + + {list.data.items.map((j) => ( + + +
{j.product_name || "—"}
+
{j.product_code}
+ + + {j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"} + {won(j.final_lowest)} + {usd(j.cost_usd)} + {dateShort(j.created_at)} + +
+ + +
+ + + ))} +
+ )} + {pages > 1 && ( + + )} +
- setSelected(null)} /> -
+ {modalJob && ( + setModalJob(null)} /> + )}
); } -function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => void }) { +// 행별 "상세"/"JSON" 버튼이 여는 모달. 상단 토글로 두 뷰 전환, JSON 뷰엔 복사 버튼. +function JobModal({ job, view, onView, onClose }: { + job: JobListItem; view: DetailView; onView: (v: DetailView) => void; onClose: () => void; +}) { const qc = useQueryClient(); const detail = useQuery({ - queryKey: ["job", job?.job_id], - queryFn: () => get(`/v1/lps/jobs/${job!.job_id}`), - enabled: !!job, + queryKey: ["job", job.job_id], + queryFn: () => get(`/v1/lps/jobs/${job.job_id}`), }); const requeue = useMutation({ - mutationFn: () => post(`/v1/lps/jobs/${job!.job_id}/requeue`), + mutationFn: () => post(`/v1/lps/jobs/${job.job_id}/requeue`), onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }), }); - - if (!job) return 왼쪽 목록에서 작업을 선택하세요; + const [copied, setCopied] = useState(false); const out = detail.data?.output as Record | undefined; + const jsonText = out ? JSON.stringify(out, null, 2) : ""; + const copyJson = async () => { + try { + await navigator.clipboard.writeText(jsonText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* 클립보드 권한 없음 — 무시 */ } + }; + return ( - 닫기}> -
- {job.job_id} - {job.product_name || "—"}{job.product_code && ({job.product_code})} - 시도 {job.attempts}/{job.max_attempts} - {job.last_error && {job.last_error}} - {out?.outcome && {out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}} - {out?.lowest && {won(out.lowest.price)} ({out.lowest.source} · {out.lowest.mall_name || "—"})} - {out?.metrics?.cost && ( - - {usd(out.metrics.cost.total_usd)} (AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)}) - - )} - {out?.metrics?.duration_ms != null && {(out.metrics.duration_ms / 1000).toFixed(1)}초} -
+ 상세 — {job.product_name || job.product_code}} + onClose={onClose} + actions={ + <> + + {view === "json" && ( + + )} + + } + > + {view === "detail" ? ( + <> +
+ {job.job_id} + {job.product_name || "—"}{job.product_code && ({job.product_code})} + 시도 {job.attempts}/{job.max_attempts} + {job.last_error && {job.last_error}} + {detail.isPending && 불러오는 중…} + {out?.outcome && {out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}} + {out?.lowest && {won(out.lowest.price)} ({out.lowest.source} · {out.lowest.mall_name || "—"})} + {out?.metrics?.cost && ( + + {usd(out.metrics.cost.total_usd)} (AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)}) + + )} + {out?.metrics?.duration_ms != null && {(out.metrics.duration_ms / 1000).toFixed(1)}초} +
- {job.status === "DEAD" && ( -
- - {requeue.isError &&

{(requeue.error as Error).message}

} - {requeue.isSuccess &&

대기열에 다시 넣었습니다 — 워커가 곧 처리합니다

} -
+ {job.status === "DEAD" && ( +
+ + {requeue.isError &&

{(requeue.error as Error).message}

} + {requeue.isSuccess &&

대기열에 다시 넣었습니다 — 워커가 곧 처리합니다

} +
+ )} + + ) : detail.isPending ? ( + + ) : detail.isError ? ( + + ) : out ? ( +
{jsonText}
+ ) : ( + 이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패) )} - - {out && ( -
- 원본 결과(JSON) -
{JSON.stringify(out, null, 2)}
-
- )} -
+ ); } diff --git a/lps-admin/src/pages/Products.tsx b/lps-admin/src/pages/Products.tsx index ccc0c7a..03cfc12 100644 --- a/lps-admin/src/pages/Products.tsx +++ b/lps-admin/src/pages/Products.tsx @@ -3,11 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { - CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, + Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { get, post } from "../api/client"; import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types"; -import { Card, Empty, ErrorNote, Loading } from "../components/ui"; +import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui"; +import { gridProps, xAxisProps, yAxisProps } from "../lib/chart"; import { dateShort, timeAgo, won } from "../lib/format"; // 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지. @@ -17,6 +18,34 @@ const SERIES = [ { key: "final", label: "최종 최저가", color: "var(--color-final)" }, ] as const; +// 커스텀 툴팁 — 둥근 카드·부드러운 그림자·토큰 색. 값 없는(못 찾은) 소스는 생략, 시리즈 순서 고정. +function ChartTooltip({ active, payload, label }: { + active?: boolean; + payload?: { dataKey?: string | number; value?: number; name?: string }[]; + label?: string; +}) { + if (!active || !payload?.length) return null; + const byKey = new Map(payload.map((p) => [p.dataKey, p])); + return ( +
+
{label}
+
    + {SERIES.map((s) => { + const p = byKey.get(s.key); + if (p?.value == null) return null; + return ( +
  • + + {s.label} + {won(p.value)} +
  • + ); + })} +
+
+ ); +} + export default function Products() { const [q, setQ] = useState(""); const [qInput, setQInput] = useState(""); @@ -28,26 +57,26 @@ export default function Products() { }); return ( -
-

상품·가격

+
+ -
{ e.preventDefault(); setQ(qInput.trim()); }}> - setQInput(e.target.value)} placeholder="상품코드 검색" aria-label="상품코드 검색" - className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" /> - -
+ setQ(qInput.trim())} /> -
- + {list.isPending && } {list.isError && } {list.data && list.data.items.length === 0 && 아직 검색된 상품이 없습니다} {list.data && list.data.items.length > 0 && ( -
    + +
      {list.data.items.map((p) => (
    • ))} -
    +
+ )} -
+
- -
+
); } function ProductDetail({ product }: { product: ProductItem | null }) { const qc = useQueryClient(); + // 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백. + const [selectedTs, setSelectedTs] = useState(null); const history = useQuery({ queryKey: ["history", product?.product_code], queryFn: () => get(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`), @@ -83,141 +114,161 @@ function ProductDetail({ product }: { product: ProductItem | null }) { onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }), }); - if (!product) return 왼쪽에서 상품을 선택하세요; + if (!product) return ( + +
목록에서 상품을 선택하세요
+
+ ); const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) })); const latest = history.data?.points.at(-1); + // 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다. + const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest; return ( - // min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지 -
- + 가격 추이 — {product.display_name || product.product_code}} hint={ - + }> {research.isSuccess && ( -

+

{research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}

)} - {research.isError && } - {history.isPending && } - {history.isError && } - {history.data && points.length === 0 && 이력이 없습니다} - {points.length > 0 && ( - <> - - - - - v.toLocaleString("ko-KR")} domain={["auto", "auto"]} /> - [won(v), name]} /> - {SERIES.map((s) => ( - + {research.isError &&
} +
+ {history.isPending ? ( +
+ ) : history.isError ? ( + + ) : points.length === 0 ? ( +
이력이 없습니다
+ ) : ( + + { + const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at; + if (ts) setSelectedTs(ts); + }}> + + + + + + + + + v.toLocaleString("ko-KR")} domain={[0, "auto"]} /> + } cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} /> + {activePoint && ( + + )} + {SERIES.filter((s) => s.key !== "final").map((s) => ( + ))} - + + -

- {SERIES.map((s) => ( - - - {s.label} - - ))} - 빈 구간 = 그 시점에 해당 소스에서 못 찾음 -

- + )} +
+ {points.length > 0 && ( + ({ label: s.label, color: s.color }))} + note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" /> )}
- {latest && } + {activePoint ? ( + + ) : ( + +
최근 검색 데이터가 없습니다
+
+ )}
); } -// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로. -// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크. -const TOP_N_PRESETS = [3, 5, 10] as const; +// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지. +const TOP_OPTIONS: { v: number | "all"; label: string }[] = [ + { v: 3, label: "3" }, { v: 5, label: "5" }, { v: 10, label: "10" }, { v: "all", label: "전체" }, +]; -function MallCompare({ latest }: { latest: PricePoint }) { +const MALL_COLS = [ + { key: "rank", label: "#", width: "2.25rem" }, + { key: "mall", label: "쇼핑몰" }, + { key: "price", label: "가격", align: "right", width: "5rem" }, +] as const; + +// 소스(발견 채널) 색·라벨 — 시리즈 색 재사용(엔터티 고정). +const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; +const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타"); + +function MallCompare({ point }: { point: PricePoint }) { const [topN, setTopN] = useState(5); - const malls = (latest.by_mall ?? []) + const malls = (point.by_mall ?? []) .filter((m) => m.price != null) .sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); const shown = topN === "all" ? malls : malls.slice(0, topN); const cheapest = malls[0]?.price ?? 0; - const maxPrice = Math.max(1, ...shown.map((m) => m.price ?? 0)); return ( - - {dateShort(latest.triggered_at)} - - {TOP_N_PRESETS.map((n) => ( - - ))} - - - - }> + }> +

+ {dateShort(point.triggered_at)} 기준 · 그래프의 시점을 클릭해 이동 +

{shown.length === 0 ? ( - 몰별 데이터 없음 +
몰별 데이터 없음
) : ( <> -
    + {shown.map((m, i) => { const price = m.price ?? 0; const delta = price - cheapest; - const src = m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; + const pct = cheapest > 0 ? (delta / cheapest) * 100 : 0; + const src = srcColor(m.source); return ( -
  • - {/* 순위 */} - - {i + 1} - - {/* 쇼핑몰 + 상품명 */} -
    -
    - - {m.mall_name || m.source} - {i === 0 && 최저} + + + {i + 1} + + +
    + {srcLabel(m.source)} + {m.mall_name || m.source} + {i === 0 && 최저}
    -
    {m.name}
    -
    - {/* 가격 + 최저가 대비 + 링크 */} -
    -
    {won(price)}
    -
    - {delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`} +
    {m.name}
    + + +
    {won(price)}
    +
    + {delta === 0 ? "최저" : `+${pct.toFixed(1)}%`} {m.detail_url && ( - 열기 ↗ + ↗ )}
    -
    - {/* 가격 비교 막대(전체 폭, 최고가 기준 상대 길이) */} -
    -
    -
    -
  • + + ); })} -
+ {topN !== "all" && malls.length > shown.length && ( -

몰 {malls.length}개 중 상위 {shown.length}개 표시 — '전체'로 모두 보기

+

몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기

)} )} diff --git a/lps-admin/src/pages/Settings.tsx b/lps-admin/src/pages/Settings.tsx index 549ca16..f2a5f7c 100644 --- a/lps-admin/src/pages/Settings.tsx +++ b/lps-admin/src/pages/Settings.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { settings } from "../api/client"; -import { Card } from "../components/ui"; +import { Button, Card, PageHeader } from "../components/ui"; export default function Settings() { const [baseUrl, setBaseUrl] = useState(settings.baseUrl()); @@ -11,7 +11,7 @@ export default function Settings() { return (
-

설정

+

서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.

- + {saved && 저장했습니다}
diff --git a/lps-admin/src/pages/TestSearch.tsx b/lps-admin/src/pages/TestSearch.tsx index 5575a16..16a1ed9 100644 --- a/lps-admin/src/pages/TestSearch.tsx +++ b/lps-admin/src/pages/TestSearch.tsx @@ -9,11 +9,38 @@ 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 { Card, Empty, ErrorNote } from "../components/ui"; +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 = { 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 = { @@ -102,54 +129,48 @@ export default function TestSearch() { }; return ( -
-

테스트 검색

-

- 임의의 상품을 실제 검색 파이프라인에 흘려보내 셀렉터·프록시·AI 매칭이 정상인지 점검합니다. - 워커가 실제로 크롤하므로 프록시 대역폭 비용이 실제로 발생하고, 결과는 비용 통계에 집계됩니다 - (상품코드는 TEST- 접두로 구분). -

+
+
+ +

+ 실제 크롤이라 프록시 비용 발생 · 비용 통계 집계 (상품코드 TEST-) +

+
-
- -
{ e.preventDefault(); if (form.product_name.trim()) submit.mutate(); }} - > - setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" /> -
- setForm((f) => ({ ...f, model: v }))} placeholder="선택" /> - setForm((f) => ({ ...f, company: v }))} placeholder="선택" /> -
- setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" /> - setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" /> -
- {/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션임을 색으로 신호(모니터링 CTA 와 구분). */} - - {jobId && ( - - )} -
- {submit.isError && } - -
- -
- {!jobId && ( - 왼쪽에서 상품명을 입력하고 검색을 실행하세요 - )} + {jobId && ( + + )} +
+ {submit.isError && } + + {jobId && ( - + {job.isError &&
} {pendingTooLong && ( @@ -164,8 +185,21 @@ export default function TestSearch() { )}
)} +
- {status === "DONE" && output && } + {/* 오른쪽: 결과 */} +
+ {status === "DONE" && output ? ( + + ) : ( + + + {!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요" + : status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요" + : "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"} + + + )}
@@ -207,9 +241,20 @@ function Progress({ status, elapsed }: { status?: string; elapsed: number }) { 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 ( <> - + {found ? (
같은 상품 찾음 @@ -217,7 +262,7 @@ function ResultView({ output }: { output: JobOutput }) { 최저가 {won(output.lowest.price)} - ({sourceLabel(output.lowest.source)}{output.lowest.mall_name ? ` · ${output.lowest.mall_name}` : ""}) + ({sourceMall(output.lowest.source, output.lowest.mall_name)}) )} @@ -231,15 +276,20 @@ function ResultView({ output }: { output: JobOutput }) { {output.stages && output.stages.length > 0 && ( - + )} -
+
{output.sources && Object.keys(output.sources).length > 0 && ( - -
    + + {output.query && ( +

    + 각 사이트 검색어 "{output.query}" +

    + )} +
      {Object.entries(output.sources).map(([s, v]) => (
    • {sourceLabel(s)} @@ -258,8 +308,8 @@ function ResultView({ output }: { output: JobOutput }) { )} {m && ( - -
        + +
        • 총 비용{usd(m.cost?.total_usd)}
        • AI 판정·검색어{usd(m.cost?.ai_usd)}
        • 프록시 대역폭{usd(m.cost?.proxy_usd)}
        • @@ -275,38 +325,52 @@ function ResultView({ output }: { output: JobOutput }) {
{output.top && output.top.length > 0 && ( - -
- - - - - - - + + + {output.top.map((p, i) => { + const src = srcColor(p.source); + const ship = shipInfo(p.shipping_fee, p.shipping_type); + return ( + + + + + + - - - {output.top.map((p, i) => ( - - - - - - - ))} - -
소스상품명가격링크
+ {sourceLabel(p.source)} + {p.name} + {won(p.price)} + {i === 0 && 최저} + + {ship.tone === "rocket" ? ( + 🚀 {ship.text} + ) : ( + {ship.text} + )} + + {p.detail_url && ( + 사이트 → + )} +
{sourceLabel(p.source)}{p.name}{won(p.price)} - {p.detail_url && 열기 ↗} -
-
+ ); + })} +
)} -
- 원본 결과(JSON) -
{JSON.stringify(output, null, 2)}
-
+
+ +
+ + {showJson && ( + setShowJson(false)} + actions={}> +
{jsonText}
+
+ )} ); } @@ -319,16 +383,27 @@ function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: numb ]; const max = Math.max(1, ...rows.map((r) => r.value)); return ( -
    - {rows.map((r, i) => ( -
  • - {r.label} -
    -
    0 ? "2px" : 0 }} aria-hidden /> -
    - {r.value} -
  • - ))} +
      + {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)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로. +
    • + {r.label} +
      +
      0 ? "3px" : 0 }} aria-hidden /> +
      + {r.value} + {/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */} + + {drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"} + +
    • + ); + })}
    ); }