fix(pdf-import): 클라이언트 분석 타임아웃·스피너 해제 보장

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) <noreply@anthropic.com>
This commit is contained in:
sangwook yoo 2026-06-02 17:52:10 +09:00
parent 761c42a637
commit 49b2d0940f
3 changed files with 32 additions and 15 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useContext } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
@ -25,6 +25,7 @@ import { normalizeDecimal } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput' import { CalculatorInput } from '@/components/common/input/CalcInput'
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
import { QcastContext } from '@/app/QcastProvider'
// [LOW-PITCH-WARN 2026-05-06] + alert // [LOW-PITCH-WARN 2026-05-06] + alert
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' 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 const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
// (ms). ( 60s)+generateContent
// backstop . abort .
const PDF_ANALYZE_TIMEOUT_MS = 120 * 1000
const PDF_PAGE_MODE = { const PDF_PAGE_MODE = {
ALL: 'all', ALL: 'all',
SPECIFY: 'specify', SPECIFY: 'specify',
@ -102,6 +107,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
const [pdfFacadePages, setPdfFacadePages] = useState('') const [pdfFacadePages, setPdfFacadePages] = useState('')
const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('')
const [pdfAnalyzing, setPdfAnalyzing] = useState(false) const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
const { setIsGlobalLoading } = useContext(QcastContext)
const pdfInputRef = useRef(null) const pdfInputRef = useRef(null)
/** /**
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드) * 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
@ -159,27 +165,36 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
} }
setPdfAnalyzing(true) setPdfAnalyzing(true)
let response setIsGlobalLoading(true)
try { const controller = new AbortController()
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS)
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' })
return false
}
let payload let response = null
let payload = null
let errorKey = null
try {
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData, signal: controller.signal })
try { try {
payload = await response.json() payload = await response.json()
} catch (e) { } catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
errorKey = 'pdf.import.error.analyze'
}
} catch (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) setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' }) setIsGlobalLoading(false)
return false
} }
setPdfAnalyzing(false) // alert/confirm ( z-index alert ).
if (errorKey) {
swalFire({ text: getMessage(errorKey), type: 'alert' })
return false
}
if (!response.ok) { if (!response.ok) {
const msg = payload?.error?.message || getMessage('pdf.import.error.analyze') const msg = payload?.error?.message || getMessage('pdf.import.error.analyze')

View File

@ -21,6 +21,7 @@
"pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?", "pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?",
"pdf.import.confirm.low.confidence": "図面の認識信頼度が低いです({0}%)。それでも外壁線を反映しますか?ページを指定して再度お試しいただくこともできます。", "pdf.import.confirm.low.confidence": "図面の認識信頼度が低いです({0}%)。それでも外壁線を反映しますか?ページを指定して再度お試しいただくこともできます。",
"pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。", "pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。",
"pdf.import.error.timeout": "解析リクエストがタイムアウトしました。ネットワーク状態を確認して再度お試しください。",
"pdf.import.error.analyze": "PDF 解析に失敗しました。", "pdf.import.error.analyze": "PDF 解析に失敗しました。",
"pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。", "pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。",
"pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。", "pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。",

View File

@ -21,6 +21,7 @@
"pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?", "pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?",
"pdf.import.confirm.low.confidence": "도면 인식 신뢰도가 낮습니다({0}%). 그래도 외곽선을 반영하시겠습니까? 페이지를 직접 지정해 다시 시도할 수도 있습니다.", "pdf.import.confirm.low.confidence": "도면 인식 신뢰도가 낮습니다({0}%). 그래도 외곽선을 반영하시겠습니까? 페이지를 직접 지정해 다시 시도할 수도 있습니다.",
"pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.", "pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.",
"pdf.import.error.timeout": "분석 요청 시간이 초과되었습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.",
"pdf.import.error.analyze": "PDF 분석에 실패했습니다.", "pdf.import.error.analyze": "PDF 분석에 실패했습니다.",
"pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.", "pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.",
"pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.", "pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",