feat(negodata/front): PriceUpdateModal 데모 → LPS 실연동
- 가짜 setTimeout 애니메이션 제거 — 상품별 POST lowest-price(큐 접수, duplicated 는 결과 대기 합류) 후 GET 5초 폴링(서버가 조회 시 lps_db 증분 동기화를 겸하므로 폴링이 곧 수집) - 요청 시각 이후 crawl_end_time 이력만 이번 결과로 인정, 완료/미발견 로그 구분(emerald/amber), 5분 대기초과분은 주기 동기화 자동반영 안내 - 종료 시 /v1/item/list invalidate → 테이블 인터넷 최저가 컬럼 갱신 - 닫기 = 폴링만 중단(서버 검색은 계속됨을 UI 에 명시) - orval 재생성: lowest-price 응답 타입화(LowestPriceEntry·lowest_price) 검증: tsc 통과, vite build 통과 (API 흐름 자체는 ③ e2e 로 검증됨) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
28b4a9e797
commit
3171311746
@ -644,7 +644,8 @@ export const useDeleteItem = <TError = void | HTTPValidationError,
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @summary 최저가 수집 요청(스텁)
|
* 상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링.
|
||||||
|
* @summary 최저가 수집 요청
|
||||||
*/
|
*/
|
||||||
export const triggerLowestPrice = (
|
export const triggerLowestPrice = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@ -690,7 +691,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type TriggerLowestPriceMutationError = void | HTTPValidationError
|
export type TriggerLowestPriceMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary 최저가 수집 요청(스텁)
|
* @summary 최저가 수집 요청
|
||||||
*/
|
*/
|
||||||
export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
|
export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
@ -706,7 +707,9 @@ export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @summary 최저가 수집 결과(스텁)
|
* 대표 최저가(items.internet_lowest_price) + 최근 수집 이력(최신순).
|
||||||
|
조회 전에 lps_db 증분 동기화를 한 번 수행해 5분 크론을 기다리지 않는다(멱등·저비용).
|
||||||
|
* @summary 최저가 수집 결과
|
||||||
*/
|
*/
|
||||||
export const getLowestPrice = (
|
export const getLowestPrice = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@ -777,7 +780,7 @@ export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPri
|
|||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
/**
|
/**
|
||||||
* @summary 최저가 수집 결과(스텁)
|
* @summary 최저가 수집 결과
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPrice>>, TError = void | HTTPValidationError>(
|
export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPrice>>, TError = void | HTTPValidationError>(
|
||||||
|
|||||||
@ -78,6 +78,10 @@ export * from './listNotificationsParams';
|
|||||||
export * from './listQuotationsParams';
|
export * from './listQuotationsParams';
|
||||||
export * from './listSuppliersParams';
|
export * from './listSuppliersParams';
|
||||||
export * from './listUsersParams';
|
export * from './listUsersParams';
|
||||||
|
export * from './lowestPriceEntry';
|
||||||
|
export * from './lowestPriceEntryCrawlEndTime';
|
||||||
|
export * from './lowestPriceEntryFailReason';
|
||||||
|
export * from './lowestPriceEntryLpPrice';
|
||||||
export * from './notificationData';
|
export * from './notificationData';
|
||||||
export * from './notificationDataCreatedAt';
|
export * from './notificationDataCreatedAt';
|
||||||
export * from './notificationDataData';
|
export * from './notificationDataData';
|
||||||
@ -278,6 +282,7 @@ export * from './resItemSupplyTypeListMsg';
|
|||||||
export * from './resLogin';
|
export * from './resLogin';
|
||||||
export * from './resLoginMsg';
|
export * from './resLoginMsg';
|
||||||
export * from './resLowestPriceResult';
|
export * from './resLowestPriceResult';
|
||||||
|
export * from './resLowestPriceResultLowestPrice';
|
||||||
export * from './resLowestPriceResultMsg';
|
export * from './resLowestPriceResultMsg';
|
||||||
export * from './resLowestPriceTrigger';
|
export * from './resLowestPriceTrigger';
|
||||||
export * from './resLowestPriceTriggerMsg';
|
export * from './resLowestPriceTriggerMsg';
|
||||||
|
|||||||
20
negodata/front/src/api/generated/model/lowestPriceEntry.ts
Normal file
20
negodata/front/src/api/generated/model/lowestPriceEntry.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
|
||||||
|
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
|
||||||
|
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최저가 수집 이력 1건(partner.item_internet_lowest_prices).
|
||||||
|
*/
|
||||||
|
export interface LowestPriceEntry {
|
||||||
|
lp_price?: LowestPriceEntryLpPrice;
|
||||||
|
website?: number;
|
||||||
|
success_yn?: boolean;
|
||||||
|
fail_reason?: LowestPriceEntryFailReason;
|
||||||
|
crawl_end_time?: LowestPriceEntryCrawlEndTime;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LowestPriceEntryCrawlEndTime = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LowestPriceEntryFailReason = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LowestPriceEntryLpPrice = number | null;
|
||||||
@ -6,11 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
import type { ErrorInfo } from './errorInfo';
|
import type { ErrorInfo } from './errorInfo';
|
||||||
import type { ResLowestPriceResultMsg } from './resLowestPriceResultMsg';
|
import type { ResLowestPriceResultMsg } from './resLowestPriceResultMsg';
|
||||||
|
import type { ResLowestPriceResultLowestPrice } from './resLowestPriceResultLowestPrice';
|
||||||
|
import type { LowestPriceEntry } from './lowestPriceEntry';
|
||||||
|
|
||||||
export interface ResLowestPriceResult {
|
export interface ResLowestPriceResult {
|
||||||
result?: ErrorInfo;
|
result?: ErrorInfo;
|
||||||
msg?: ResLowestPriceResultMsg;
|
msg?: ResLowestPriceResultMsg;
|
||||||
item_id?: string;
|
item_id?: string;
|
||||||
results?: unknown[];
|
lowest_price?: ResLowestPriceResultLowestPrice;
|
||||||
|
results?: LowestPriceEntry[];
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResLowestPriceResultLowestPrice = number | null;
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
|
import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
|
import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item';
|
||||||
import type { Product } from '../types';
|
import type { Product } from '../types';
|
||||||
|
|
||||||
type PriceUpdateModalProps = {
|
type PriceUpdateModalProps = {
|
||||||
@ -15,62 +17,113 @@ type PriceUpdateModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
|
const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함)
|
||||||
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
|
const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분)
|
||||||
|
|
||||||
|
// 인터넷 최저가 실시간 수집 모달 — LPS 연동.
|
||||||
|
// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료).
|
||||||
|
// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
|
||||||
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
|
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
|
||||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isCrawling, setIsCrawling] = useState(false);
|
const [isCrawling, setIsCrawling] = useState(false);
|
||||||
const [crawlingProgress, setCrawlingProgress] = useState(0);
|
const [crawlingProgress, setCrawlingProgress] = useState(0);
|
||||||
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
|
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
|
||||||
|
const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속)
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleStartCrawling = () => {
|
const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]);
|
||||||
|
const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id;
|
||||||
|
|
||||||
|
const handleStartCrawling = async () => {
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) {
|
||||||
toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.');
|
toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelledRef.current = false;
|
||||||
setIsCrawling(true);
|
setIsCrawling(true);
|
||||||
setCrawlingProgress(10);
|
setCrawlingProgress(5);
|
||||||
setCrawlerLogs([
|
setCrawlerLogs([
|
||||||
'[System] 실시간 최저가 수집용 웹 크롤러 엔진 가동...',
|
'[System] 인터넷 최저가 검색(LPS) 요청 접수 중...',
|
||||||
'[System] API Endpoint: https://api.commerce-crawler.co.kr/v2/itemsync',
|
`[Target] 선택된 ${selectedIds.length}개 상품`,
|
||||||
`[Target] 선택된 ${selectedIds.length}개 상품의 고유 코드 및 품목 매핑 중...`,
|
|
||||||
]);
|
]);
|
||||||
|
const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정
|
||||||
|
|
||||||
// Fast simulation timers
|
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
|
||||||
setTimeout(() => {
|
const pending = new Set<string>();
|
||||||
setCrawlingProgress(35);
|
for (const id of selectedIds) {
|
||||||
setCrawlerLogs((prev) => [
|
try {
|
||||||
...prev,
|
const r = await triggerLowestPrice(id);
|
||||||
...selectedIds.map((id) => {
|
if (r.status === 'queued') {
|
||||||
const p = products.find((prod) => prod.item_id === id);
|
pending.add(id);
|
||||||
return `[크롤링] '${p?.name || id}' 인터넷 최저가 비교 검색 수집 진행`;
|
pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`);
|
||||||
}),
|
} else if (r.status === 'duplicated') {
|
||||||
`[Search] 외부 커머스 유통 플랫폼(Coupang, Gmarket, Danawa) 지표 추출 시작...`,
|
pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다
|
||||||
]);
|
pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`);
|
||||||
}, 500);
|
} else {
|
||||||
|
pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pushLog(`[오류] '${nameOf(id)}' 요청 실패`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (pending.size === 0) {
|
||||||
setCrawlingProgress(72);
|
showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error');
|
||||||
setCrawlerLogs((prev) => [
|
setIsCrawling(false);
|
||||||
...prev,
|
setCrawlingProgress(0);
|
||||||
`[OCR] 수집 완료된 실시간 HTML/DOM 가격 노드 데이터 분석 중...`,
|
return;
|
||||||
`[Sync] 정제 단가 적용 (부가세 보정 및 할인 쿠폰 혜택가 산정)`,
|
}
|
||||||
]);
|
setCrawlingProgress(15);
|
||||||
}, 1100);
|
pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`);
|
||||||
|
|
||||||
setTimeout(() => {
|
// 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리.
|
||||||
|
const total = pending.size;
|
||||||
|
let found = 0;
|
||||||
|
let notFound = 0;
|
||||||
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
|
while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) {
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
for (const id of [...pending]) {
|
||||||
|
try {
|
||||||
|
const r = await getLowestPrice(id);
|
||||||
|
const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt);
|
||||||
|
if (!fresh) continue;
|
||||||
|
pending.delete(id);
|
||||||
|
if (fresh.success_yn && fresh.lp_price != null) {
|
||||||
|
found += 1;
|
||||||
|
pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`);
|
||||||
|
} else {
|
||||||
|
notFound += 1;
|
||||||
|
pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 일시 오류는 다음 tick 재시도 */
|
||||||
|
}
|
||||||
|
setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료
|
||||||
|
const timedOut = pending.size > 0 && !cancelledRef.current;
|
||||||
|
if (timedOut) {
|
||||||
|
pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`);
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
|
||||||
setCrawlingProgress(100);
|
setCrawlingProgress(100);
|
||||||
// NOTE: 인터넷 최저가 동기화는 전용 엔드포인트 연동 예정. 현재는 진행 애니메이션만 데모(서버 미반영).
|
if (!cancelledRef.current) {
|
||||||
showToast(`선택한 ${selectedIds.length}개 상품의 인터넷 최저가 동기화는 준비 중입니다(데모).`, 'info');
|
showToast(
|
||||||
|
`최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`,
|
||||||
|
found > 0 ? 'success' : 'info',
|
||||||
|
);
|
||||||
|
}
|
||||||
onDone();
|
onDone();
|
||||||
setIsCrawling(false);
|
setIsCrawling(false);
|
||||||
onClose();
|
onClose();
|
||||||
setCrawlingProgress(0);
|
setCrawlingProgress(0);
|
||||||
setCrawlerLogs([]);
|
setCrawlerLogs([]);
|
||||||
}, 2000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -81,7 +134,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Globe className="text-rose-500 animate-pulse" size={18} />
|
<Globe className="text-rose-500 animate-pulse" size={18} />
|
||||||
<Typography variant="h3">인터넷 최저가 API 실시간 수집 및 동기화</Typography>
|
<Typography variant="h3">인터넷 최저가 실시간 수집 및 동기화</Typography>
|
||||||
</div>
|
</div>
|
||||||
{!isCrawling && (
|
{!isCrawling && (
|
||||||
<button
|
<button
|
||||||
@ -99,7 +152,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
|
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
|
||||||
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
|
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 오픈마켓, 다나와, 쿠팡 및 B2B 공공 유통망의 최저가 데이터를 수집 및 비교 분석하여 최신 최저가(minPrice) 필드로 다이렉트 동기화합니다.
|
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다.
|
||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +169,9 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
<div className="flex items-center gap-1.5 font-mono">
|
<div className="flex items-center gap-1.5 font-mono">
|
||||||
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
|
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
|
||||||
<span className="text-muted-foreground">→</span>
|
<span className="text-muted-foreground">→</span>
|
||||||
<span className="text-rose-500 font-bold">자동 가공 수집</span>
|
<span className="text-rose-500 font-bold">
|
||||||
|
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
@ -130,7 +185,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5 font-bold text-foreground">
|
<span className="flex items-center gap-1.5 font-bold text-foreground">
|
||||||
<Loader2 size={13} className="animate-spin text-rose-500" />
|
<Loader2 size={13} className="animate-spin text-rose-500" />
|
||||||
실시간 크롤링 엔진 데이터 동기화 동작 중...
|
최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다)
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
|
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +203,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
{crawlerLogs.map((log, idx) => (
|
{crawlerLogs.map((log, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-1">
|
<div key={idx} className="flex items-start gap-1">
|
||||||
<span className="text-zinc-600 select-none shrink-0">></span>
|
<span className="text-zinc-600 select-none shrink-0">></span>
|
||||||
<span className={`text-left break-all ${log.includes('[System]') ? 'text-zinc-400 font-bold' : log.includes('[OCR]') ? 'text-blue-400' : 'text-emerald-400'}`}>
|
<span className={`text-left break-all ${log.includes('[System]') || log.includes('[Search]') ? 'text-zinc-400 font-bold' : log.includes('[완료]') ? 'text-emerald-400' : log.includes('[미발견]') || log.includes('[불가]') || log.includes('[오류]') || log.includes('[대기초과]') ? 'text-amber-400' : 'text-rose-300'}`}>
|
||||||
{log}
|
{log}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -159,7 +214,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
|
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
|
||||||
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
|
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
|
||||||
<Typography variant="muted" className="text-[11px]">
|
<Typography variant="muted" className="text-[11px]">
|
||||||
"최저가 업데이트 시작" 버튼을 누르시면 실시간 스크래핑 엔진이 시작됩니다.
|
"인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -167,7 +222,15 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
|||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={isCrawling} onClick={onClose}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>
|
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user