From 49b2d0940f24f037952d8ce4ac57a3008fd11ab6 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Tue, 2 Jun 2026 17:52:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(pdf-import):=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EB=B6=84=EC=84=9D=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=C2=B7=EC=8A=A4=ED=94=BC=EB=84=88=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF 분석 요청 실패/행(hang) 시 전역 스피너가 영구히 멈춰 화면 복귀가 불가능하던 문제를 해소한다. - 네트워크 구간을 try/finally 로 감싸 성공·실패·타임아웃 어떤 경로든 스피너(pdfAnalyzing·isGlobalLoading)를 반드시 해제하도록 보장 - AbortController + 120s 타임아웃으로 무한 행 차단 - alert 은 스피너 해제 후 표시(스피너 z-index 가 alert 보다 높아 가려지는 것 방지) - pdf.import.error.timeout 메시지 키 추가(ko/ja) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../placementShape/PlacementShapeSetting.jsx | 45 ++++++++++++------- src/locales/ja.json | 1 + src/locales/ko.json | 1 + 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index bad40354..55af57f0 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useContext } from 'react' import Image from 'next/image' import { useMessage } from '@/hooks/useMessage' import { usePopup } from '@/hooks/usePopup' @@ -25,6 +25,7 @@ import { normalizeDecimal } from '@/util/input-utils' import { CalculatorInput } from '@/components/common/input/CalcInput' import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' import { logger } from '@/util/logger' +import { QcastContext } from '@/app/QcastProvider' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { useSwal } from '@/hooks/useSwal' import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' @@ -39,6 +40,10 @@ const INPUT_MODE = { const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024 +// 분석 요청 타임아웃(ms). 서버 라우트는 파일 처리(최대 60s)+generateContent 로 길어질 수 있어 +// 클라이언트에서 backstop 을 둔다. 이 시간을 넘기면 abort 하여 스피너가 영구 멈추지 않도록 한다. +const PDF_ANALYZE_TIMEOUT_MS = 120 * 1000 + const PDF_PAGE_MODE = { ALL: 'all', SPECIFY: 'specify', @@ -102,6 +107,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla const [pdfFacadePages, setPdfFacadePages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfAnalyzing, setPdfAnalyzing] = useState(false) + const { setIsGlobalLoading } = useContext(QcastContext) const pdfInputRef = useRef(null) /** * 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드) @@ -159,28 +165,37 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla } setPdfAnalyzing(true) - let response + setIsGlobalLoading(true) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS) + + let response = null + let payload = null + let errorKey = null try { - response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) + response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData, signal: controller.signal }) + try { + payload = await response.json() + } catch (e) { + logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) + errorKey = 'pdf.import.error.analyze' + } } catch (e) { - logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e) + logger.error('[PlacementShapeSetting] PDF analyze request failed', e?.message || e) + errorKey = e?.name === 'AbortError' ? 'pdf.import.error.timeout' : 'pdf.import.error.network' + } finally { + // 성공·실패·타임아웃 어떤 경로로 끝나든 스피너를 반드시 해제한다(영구 멈춤 방지). + clearTimeout(timeoutId) setPdfAnalyzing(false) - swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' }) - return false + setIsGlobalLoading(false) } - let payload - try { - payload = await response.json() - } catch (e) { - logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) - setPdfAnalyzing(false) - swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' }) + // alert/confirm 은 스피너 해제 후 표시한다(스피너 z-index 가 alert 보다 높아 가려지는 것 방지). + if (errorKey) { + swalFire({ text: getMessage(errorKey), type: 'alert' }) return false } - setPdfAnalyzing(false) - if (!response.ok) { const msg = payload?.error?.message || getMessage('pdf.import.error.analyze') swalFire({ text: msg, type: 'alert' }) diff --git a/src/locales/ja.json b/src/locales/ja.json index d1873a7c..2e98e8e1 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -21,6 +21,7 @@ "pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?", "pdf.import.confirm.low.confidence": "図面の認識信頼度が低いです({0}%)。それでも外壁線を反映しますか?ページを指定して再度お試しいただくこともできます。", "pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。", + "pdf.import.error.timeout": "解析リクエストがタイムアウトしました。ネットワーク状態を確認して再度お試しください。", "pdf.import.error.analyze": "PDF 解析に失敗しました。", "pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。", "pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。", diff --git a/src/locales/ko.json b/src/locales/ko.json index ada74c21..a3645a44 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -21,6 +21,7 @@ "pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?", "pdf.import.confirm.low.confidence": "도면 인식 신뢰도가 낮습니다({0}%). 그래도 외곽선을 반영하시겠습니까? 페이지를 직접 지정해 다시 시도할 수도 있습니다.", "pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.", + "pdf.import.error.timeout": "분석 요청 시간이 초과되었습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.", "pdf.import.error.analyze": "PDF 분석에 실패했습니다.", "pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.", "pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",