From b01206e9e3d91fc8ffeaab4d9e249a9902381aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Wed, 3 Sep 2025 21:46:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 14 +++++++++++++- src/pages/myPage/index.tsx | 29 ++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index bad8298..5ae666d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import BALLFiNLogo from "../../assets/BALLFiN.svg"; import { Link, useLocation } from "react-router-dom"; import { Menu, X, LogOut, User, Settings } from "lucide-react"; @@ -40,6 +40,7 @@ const Header = () => { message: string; type: "success" | "error"; }>({ show: false, message: "", type: "success" }); + const wasLoggedInRef = useRef(false); useEffect(() => { // 로그인 상태 및 사용자 정보 확인 @@ -47,6 +48,15 @@ const Header = () => { const token = localStorage.getItem("access_token"); if (token) { + // 로그인 전환 토스트 + if (!wasLoggedInRef.current) { + setToast({ + show: true, + message: "로그인되었습니다.", + type: "success", + }); + } + wasLoggedInRef.current = true; setIsLoggedIn(true); // 먼저 localStorage에서 사용자 정보 확인 @@ -81,11 +91,13 @@ const Header = () => { setIsLoggedIn(false); setUserName(""); setUserEmail(""); + wasLoggedInRef.current = false; } } else { setIsLoggedIn(false); setUserName(""); setUserEmail(""); + wasLoggedInRef.current = false; } }; diff --git a/src/pages/myPage/index.tsx b/src/pages/myPage/index.tsx index 31a268f..6f5bf18 100644 --- a/src/pages/myPage/index.tsx +++ b/src/pages/myPage/index.tsx @@ -11,6 +11,7 @@ import { LogOut, Edit3, } from "lucide-react"; +import Toast from "@/components/common/Toast"; export default function MyPage() { const [user] = useState({ @@ -21,6 +22,10 @@ export default function MyPage() { membership: "프리미엄", joinDate: "2024년 1월", }); + const [toast, setToast] = useState<{ + message: string; + type: "success" | "error"; + } | null>(null); const menuItems = [ { @@ -61,13 +66,31 @@ export default function MyPage() { }, ]; - const handleLogout = () => { - // 로그아웃 로직 - console.log("로그아웃"); + const handleLogout = async () => { + const { logout } = await import("@/api/auth/logoutApi"); + try { + await logout(); + setToast({ message: "로그아웃 되었습니다.", type: "success" }); + } catch (_) { + setToast({ message: "로그아웃 중 오류가 발생했습니다.", type: "error" }); + } finally { + localStorage.removeItem("access_token"); + setTimeout(() => { + window.location.href = "/"; + }, 800); + } }; return (
+ {toast && ( + setToast(null)} + duration={1500} + /> + )}
{/* 프로필 카드 */} Date: Wed, 3 Sep 2025 22:00:59 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8:=20=ED=97=A4=EB=8D=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=ED=9A=8C=201=EC=9C=BC=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth/loginApi.ts | 3 +++ src/components/layout/Header.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/api/auth/loginApi.ts b/src/api/auth/loginApi.ts index 7686e89..001b6d3 100644 --- a/src/api/auth/loginApi.ts +++ b/src/api/auth/loginApi.ts @@ -68,6 +68,9 @@ export const login = async ( ? responseData.access_token : `Bearer ${responseData.access_token}`; + // 헤더에서 첫 로그인만 토스트를 띄우기 위한 플래그 + sessionStorage.setItem("just_logged_in", "1"); + return { token, user: responseData.user, diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5ae666d..619f93f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -48,13 +48,15 @@ const Header = () => { const token = localStorage.getItem("access_token"); if (token) { - // 로그인 전환 토스트 - if (!wasLoggedInRef.current) { + // 첫 로그인 플래그 확인 + const justLoggedIn = sessionStorage.getItem("just_logged_in"); + if (justLoggedIn) { setToast({ show: true, message: "로그인되었습니다.", type: "success", }); + sessionStorage.removeItem("just_logged_in"); } wasLoggedInRef.current = true; setIsLoggedIn(true); From d304084f76ff48d5a31c790106088467ee257035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Thu, 4 Sep 2025 03:05:15 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8:=20=EA=B8=B0=EC=88=A0=EC=A0=81?= =?UTF-8?q?=EB=B6=84=EC=84=9D,=20=EC=9E=AC=EB=AC=B4=EB=B6=84=EC=84=9D=20ll?= =?UTF-8?q?m=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=9C=EB=B9=99=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/stock/detail.ts | 21 ++++ .../stockDetail/FinancialStatement.tsx | 7 +- .../stockDetail/TechnicalAnalysis.tsx | 103 +++++++++--------- src/lib/axiosInstance.ts | 2 +- src/pages/stock/StockDetailPage.tsx | 85 ++++++++++++--- 5 files changed, 147 insertions(+), 71 deletions(-) diff --git a/src/api/stock/detail.ts b/src/api/stock/detail.ts index 7eb88ba..ffe7b1b 100644 --- a/src/api/stock/detail.ts +++ b/src/api/stock/detail.ts @@ -40,3 +40,24 @@ export async function getStockChart( ); return data; } + +// Total analysis (LLM) API +export interface TotalAnalysisResponse { + main_analysis?: string; + volatility_analysis?: string; + volume_analysis?: string; + fin_total_analysis?: string; + company_analysis?: string; + total_analysis?: string; + combined_technical_analysis?: string; +} + +export async function getTotalAnalysis( + stockCode: string +): Promise { + const { data } = await axiosInstance.get( + `/api/info/total_analysis/${encodeURIComponent(stockCode)}`, + { timeout: 60000 } // LLM 분석은 60초 타임아웃 + ); + return data; +} diff --git a/src/components/stockDetail/FinancialStatement.tsx b/src/components/stockDetail/FinancialStatement.tsx index 5938328..e930314 100644 --- a/src/components/stockDetail/FinancialStatement.tsx +++ b/src/components/stockDetail/FinancialStatement.tsx @@ -33,7 +33,7 @@ export default function FinancialStatement({ }: FinancialStatementProps) { const [hoveredIndicator, setHoveredIndicator] = useState(null); - const company = analysis?.company_analysis; + const company = analysis?.company_data; // 재무 데이터 const isAnalysisLoading = !company; // 간단 스켈레톤 텍스트 @@ -303,7 +303,10 @@ export default function FinancialStatement({
) : (

- {safeText(company?.total_analysis)} + {safeText( + analysis?.company_analysis ?? + "재무 분석 정보를 불러오고 있습니다." + )}

)}
diff --git a/src/components/stockDetail/TechnicalAnalysis.tsx b/src/components/stockDetail/TechnicalAnalysis.tsx index 1bcb525..736b924 100644 --- a/src/components/stockDetail/TechnicalAnalysis.tsx +++ b/src/components/stockDetail/TechnicalAnalysis.tsx @@ -68,26 +68,34 @@ export default function TechnicalAnalysis({ return "text-blue-600"; }; - const main = analysis?.main_analysis; - const vola = analysis?.volatility_analysis; - const vol = analysis?.volume_analysis; + // API 응답이 문자열이므로 직접 사용 + const mainAnalysisText = analysis?.main_analysis; + const volatilityAnalysisText = analysis?.volatility_analysis; + const volumeAnalysisText = analysis?.volume_analysis; + const combinedAnalysisText = analysis?.combined_technical_analysis; + + // 기존 기술적 지표 데이터 + const mainData = analysis?.main_analysis_data; + const volaData = analysis?.volatility_analysis_data; + const volData = analysis?.volume_analysis_data; const isAnalysisLoading = !analysis; - // 기술적 지표 값 (없으면 기본값) - const rsi = main?.rsi?.value ?? 28.4; - const dailyRange = vola?.volatility?.value?.volatility_percent ?? 2.8; - const avgVolatility = vola?.volatility?.value?.avg_volatility_percent ?? 2.8; - const currentVolume = toNum(vol?.volume?.value?.volume) || 15234567; - const avgVolume = toNum(vol?.volume?.value?.avg_volume_20) || 12456789; + // 기술적 지표 값 (실제 데이터 사용) + const rsi = mainData?.rsi?.value ?? 28.4; + const dailyRange = volaData?.volatility?.value?.volatility_percent ?? 2.8; + const avgVolatility = + volaData?.volatility?.value?.avg_volatility_percent ?? 2.8; + const currentVolume = toNum(volData?.volume?.value?.volume) || 15234567; + const avgVolume = toNum(volData?.volume?.value?.avg_volume_20) || 12456789; const volumeRatio = ( ((currentVolume - avgVolume) / (avgVolume || 1)) * 100 ).toFixed(1); const mfiValue = - typeof vol?.mfi?.value === "number" ? vol.mfi.value : undefined; - const obvValue = toNum(vol?.obv?.value?.obv); - const obvMa20Value = toNum(vol?.obv?.value?.obv_ma20); + typeof volData?.mfi?.value === "number" ? volData.mfi.value : undefined; + const obvValue = toNum(volData?.obv?.value?.obv); + const obvMa20Value = toNum(volData?.obv?.value?.obv_ma20); const tabs = [ { id: "summary", label: "주요 지표", icon: Target }, @@ -113,20 +121,21 @@ export default function TechnicalAnalysis({
{isAnalysisLoading ? ( ) : ( - (main?.moving_average?.arrangement?.status ?? "정보 없음") + (mainData?.moving_average?.arrangement?.status ?? + "정보 없음") )}
{hoveredKey === "summary_ma" && (
- {main?.moving_average?.arrangement?.description ?? - main?.moving_average?.price_vs_ma20?.description ?? - ""} + {mainData?.moving_average?.arrangement?.description ?? + mainData?.moving_average?.price_vs_ma20?.description ?? + "이동평균선 분석 정보"}
@@ -144,17 +153,19 @@ export default function TechnicalAnalysis({
{isAnalysisLoading ? ( ) : ( - (main?.stochastic?.status ?? "정보 없음") + (mainData?.stochastic?.status ?? "정보 없음") )}
{hoveredKey === "summary_stoch" && (
-
{main?.stochastic?.analysis ?? ""}
+
+ {mainData?.stochastic?.analysis ?? "스토캐스틱 분석 정보"} +
)} @@ -169,12 +180,12 @@ export default function TechnicalAnalysis({ RSI
{isAnalysisLoading ? ( ) : ( - (main?.rsi?.status ?? "중립") + (mainData?.rsi?.status ?? "중립") )}
{hoveredKey === "summary_rsi" && ( @@ -196,17 +207,17 @@ export default function TechnicalAnalysis({
{isAnalysisLoading ? ( ) : ( - (main?.rsi?.status ?? "중립") + (mainData?.macd?.status ?? "중립") )}
{hoveredKey === "summary_total" && (
-
{main?.macd?.analysis ?? ""}
+
{mainData?.macd?.analysis ?? "종합 신호 분석"}
)} @@ -225,7 +236,7 @@ export default function TechnicalAnalysis({ ) : (

- {main?.total_analysis ?? "분석 정보를 불러오고 있습니다."} + {mainAnalysisText ?? "분석 정보를 불러오고 있습니다."}

)} @@ -287,13 +298,7 @@ export default function TechnicalAnalysis({ {hoveredKey === "vol_avg" && (
-
- RVI:{" "} - {typeof vola?.rvi?.value?.rvi === "number" - ? vola.rvi.value.rvi.toFixed(4) - : "-"}{" "} - | ATR: {vola?.atr?.value?.atr ?? "-"} -
+
RVI: - | ATR: -
)} @@ -311,17 +316,17 @@ export default function TechnicalAnalysis({ ATR
{isAnalysisLoading ? ( ) : ( - (vola?.atr?.status ?? "정보 없음") + (volaData?.atr?.status ?? "정보 없음") )}
{hoveredKey === "vol_atr" && (
-
{vola?.atr?.analysis ?? ""}
+
{volaData?.atr?.analysis ?? "ATR 분석 정보"}
)} @@ -336,17 +341,17 @@ export default function TechnicalAnalysis({ RVI
{isAnalysisLoading ? ( ) : ( - (vola?.rvi?.status ?? "정보 없음") + (volaData?.rvi?.status ?? "정보 없음") )}
{hoveredKey === "vol_rvi" && (
-
{vola?.rvi?.analysis ?? ""}
+
{volaData?.rvi?.analysis ?? "RVI 분석 정보"}
)} @@ -366,8 +371,7 @@ export default function TechnicalAnalysis({ ) : (

- {vola?.total_analysis ?? - vola?.volatility?.analysis ?? + {volatilityAnalysisText ?? "변동성 분석 정보를 불러오고 있습니다."}

)} @@ -429,12 +433,12 @@ export default function TechnicalAnalysis({ MFI
{isAnalysisLoading ? ( ) : ( - (vol?.mfi?.status ?? "정보 없음") + (volData?.mfi?.status ?? "정보 없음") )}
{hoveredKey === "vol_mfi_card" && ( @@ -444,7 +448,7 @@ export default function TechnicalAnalysis({ ? `값 ${mfiValue.toFixed(2)}` : ""} -
{vol?.mfi?.analysis ?? ""}
+
{volData?.mfi?.analysis ?? "MFI 분석 정보"}
)} @@ -459,12 +463,12 @@ export default function TechnicalAnalysis({ OBV
{isAnalysisLoading ? ( ) : ( - (vol?.obv?.status ?? "정보 없음") + (volData?.obv?.status ?? "정보 없음") )}
{hoveredKey === "vol_obv_card" && ( @@ -473,7 +477,7 @@ export default function TechnicalAnalysis({ {obvValue ? obvValue.toLocaleString() : "-"} / MA20{" "} {obvMa20Value ? obvMa20Value.toLocaleString() : "-"} -
{vol?.obv?.analysis ?? ""}
+
{volData?.obv?.analysis ?? "OBV 분석 정보"}
)} @@ -493,8 +497,7 @@ export default function TechnicalAnalysis({ ) : (

- {vol?.total_analysis ?? - vol?.volume?.analysis ?? + {volumeAnalysisText ?? `평균 대비 ${volumeRatio}% 수준의 거래량입니다.`}

)} @@ -519,7 +522,7 @@ export default function TechnicalAnalysis({ ) : (

- {analysis?.fin_total_analysis ?? + {combinedAnalysisText ?? "종합 분석 정보를 불러오고 있습니다."}

)} diff --git a/src/lib/axiosInstance.ts b/src/lib/axiosInstance.ts index b3d75d0..7151fc8 100644 --- a/src/lib/axiosInstance.ts +++ b/src/lib/axiosInstance.ts @@ -7,7 +7,7 @@ const baseURL = import.meta.env.DEV export const axiosInstance = axios.create({ baseURL, - timeout: 15000, + timeout: 30000, // 30초로 증가 headers: { "Content-Type": "application/json", }, diff --git a/src/pages/stock/StockDetailPage.tsx b/src/pages/stock/StockDetailPage.tsx index 6e120c8..7ca8622 100644 --- a/src/pages/stock/StockDetailPage.tsx +++ b/src/pages/stock/StockDetailPage.tsx @@ -10,6 +10,7 @@ import { getStockInfoByCode, getCompanyInfoByCode, getStockChart, + getTotalAnalysis, } from "@/api/stock"; import { getNewsByCompany } from "@/api/news"; @@ -185,24 +186,74 @@ export default function StockDetailPage() { if (!cancelled) setStock(mapped); - // 기술적/재무 데이터 매핑 - if (!cancelled && companyInfo) { - // setTechSummary(companyInfo.main_analysis ?? null); - setCompanyAnalysis(companyInfo); - setFinancialData({ - revenue: - (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, - netIncome: - (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, - debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, - roe: companyInfo.company_analysis?.ROE ?? 0, - per: companyInfo.company_analysis?.PER ?? 0, - pbr: companyInfo.company_analysis?.PBR ?? 0, - dividendYield: 0, - }); + // LLM 종합 분석 요청 + try { + const totalAnalysis = await getTotalAnalysis(code); + console.log("Total Analysis Response:", totalAnalysis); + + // 기술적/재무 데이터 매핑 + if (!cancelled && companyInfo) { + const mergedAnalysis = { + ...totalAnalysis, + company_analysis: totalAnalysis.company_analysis, // API에서 받은 재무 분석 텍스트 + company_data: companyInfo.company_analysis ?? companyInfo, // 기업 재무 데이터 + main_analysis: totalAnalysis.main_analysis, + volume_analysis: totalAnalysis.volume_analysis, + volatility_analysis: totalAnalysis.volatility_analysis, + combined_technical_analysis: + totalAnalysis.combined_technical_analysis, + fin_total_analysis: totalAnalysis.fin_total_analysis, + // 기존 기술적 지표 데이터도 포함 + main_analysis_data: companyInfo.main_analysis, + volatility_analysis_data: companyInfo.volatility_analysis, + volume_analysis_data: companyInfo.volume_analysis, + }; + setCompanyAnalysis(mergedAnalysis); + setFinancialData({ + revenue: + (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, + netIncome: + (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, + debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, + roe: companyInfo.company_analysis?.ROE ?? 0, + per: companyInfo.company_analysis?.PER ?? 0, + pbr: companyInfo.company_analysis?.PBR ?? 0, + dividendYield: 0, + }); + } + } catch (error) { + console.error("Total Analysis API Error:", error); + // API 실패 시에도 기본 분석 데이터 설정 + if (!cancelled && companyInfo) { + const fallbackAnalysis = { + company_analysis: "재무 분석 정보를 불러오고 있습니다.", + company_data: companyInfo.company_analysis ?? companyInfo, + main_analysis: "분석 정보를 불러오고 있습니다.", + volume_analysis: "분석 정보를 불러오고 있습니다.", + volatility_analysis: "분석 정보를 불러오고 있습니다.", + combined_technical_analysis: "분석 정보를 불러오고 있습니다.", + fin_total_analysis: "분석 정보를 불러오고 있습니다.", + // 기존 기술적 지표 데이터도 포함 + main_analysis_data: companyInfo.main_analysis, + volatility_analysis_data: companyInfo.volatility_analysis, + volume_analysis_data: companyInfo.volume_analysis, + }; + setCompanyAnalysis(fallbackAnalysis); + setFinancialData({ + revenue: + (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, + netIncome: + (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, + debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, + roe: companyInfo.company_analysis?.ROE ?? 0, + per: companyInfo.company_analysis?.PER ?? 0, + pbr: companyInfo.company_analysis?.PBR ?? 0, + dividendYield: 0, + }); + } } - // 차트 데이터 API 연동 (일봉 180개 기본) + // 차트, 뉴스 const chartRes = await getStockChart(code, "D", 180); const chartData: HistoricalData[] = chartRes.candles.map((c) => ({ date: c.date, @@ -213,7 +264,6 @@ export default function StockDetailPage() { volume: c.volume, })); - // 기업 뉴스 API 연동 const companyNews = await getNewsByCompany(code, 10); const mappedNews: NewsListItem[] = companyNews.map((n) => ({ id: n.id, @@ -237,7 +287,6 @@ export default function StockDetailPage() { setHistoricalData(chartData); setNews(mappedNews); setIsChartLoading(false); - // financialData는 company API에서 세팅됨 (실패 시에만 목데이터 유지) if (!financialData) setFinancialData(mockFinancialData); } } catch { From acaeed092499d58b8361a3e05e612893257608ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Thu, 4 Sep 2025 03:11:07 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8:=20=EC=A3=BC=EC=8B=9D=EC=84=B8?= =?UTF-8?q?=EB=B6=80=20api=20=EB=A1=9C=EB=94=A9=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stockDetail/FinancialStatement.tsx | 4 +- .../stockDetail/TechnicalAnalysis.tsx | 28 +-- src/pages/stock/StockDetailPage.tsx | 178 ++++++++++-------- 3 files changed, 116 insertions(+), 94 deletions(-) diff --git a/src/components/stockDetail/FinancialStatement.tsx b/src/components/stockDetail/FinancialStatement.tsx index e930314..319c9f1 100644 --- a/src/components/stockDetail/FinancialStatement.tsx +++ b/src/components/stockDetail/FinancialStatement.tsx @@ -14,6 +14,7 @@ interface FinancialData { interface FinancialStatementProps { data: FinancialData; analysis?: any; // /info/company/{code} 응답 객체 (company_analysis 포함) + isAnalysisLoading?: boolean; // LLM 분석 로딩 상태 } interface FinancialIndicator { @@ -30,6 +31,7 @@ interface FinancialIndicator { export default function FinancialStatement({ data, analysis, + isAnalysisLoading: _isAnalysisLoading = false, }: FinancialStatementProps) { const [hoveredIndicator, setHoveredIndicator] = useState(null); @@ -296,7 +298,7 @@ export default function FinancialStatement({

종합 해석

- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
diff --git a/src/components/stockDetail/TechnicalAnalysis.tsx b/src/components/stockDetail/TechnicalAnalysis.tsx index 736b924..2640924 100644 --- a/src/components/stockDetail/TechnicalAnalysis.tsx +++ b/src/components/stockDetail/TechnicalAnalysis.tsx @@ -34,6 +34,8 @@ interface TechnicalAnalysisProps { stock: StockDetail; historicalData: HistoricalData[]; analysis?: any; // /info/company/{code} 응답 전체 또는 필요한 섹션 포함 객체 + isAnalysisLoading?: boolean; // LLM 분석 로딩 상태 + isTechnicalLoading?: boolean; // 기술적 분석 데이터 로딩 상태 } type TabType = "summary" | "volatility" | "volume" | "overview"; @@ -42,6 +44,8 @@ export default function TechnicalAnalysis({ stock: _stock, historicalData: _historicalData, analysis, + isAnalysisLoading: _isAnalysisLoading = false, + isTechnicalLoading: _isTechnicalLoading = true, }: TechnicalAnalysisProps) { const [activeTab, setActiveTab] = useState("summary"); const [hoveredKey, setHoveredKey] = useState(null); @@ -123,7 +127,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (mainData?.moving_average?.arrangement?.status ?? @@ -155,7 +159,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (mainData?.stochastic?.status ?? "정보 없음") @@ -182,7 +186,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (mainData?.rsi?.status ?? "중립") @@ -209,7 +213,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (mainData?.macd?.status ?? "중립") @@ -229,7 +233,7 @@ export default function TechnicalAnalysis({ 주요지표 분석
- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
@@ -318,7 +322,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (volaData?.atr?.status ?? "정보 없음") @@ -343,7 +347,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (volaData?.rvi?.status ?? "정보 없음") @@ -364,7 +368,7 @@ export default function TechnicalAnalysis({ 변동성 분석
- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
@@ -435,7 +439,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (volData?.mfi?.status ?? "정보 없음") @@ -465,7 +469,7 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( (volData?.obv?.status ?? "정보 없음") @@ -490,7 +494,7 @@ export default function TechnicalAnalysis({
거래량 분석
- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
@@ -515,7 +519,7 @@ export default function TechnicalAnalysis({ 기술적 분석 종합 해석
- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
diff --git a/src/pages/stock/StockDetailPage.tsx b/src/pages/stock/StockDetailPage.tsx index 7ca8622..66fb2dc 100644 --- a/src/pages/stock/StockDetailPage.tsx +++ b/src/pages/stock/StockDetailPage.tsx @@ -89,6 +89,7 @@ export default function StockDetailPage() { null ); const [companyAnalysis, setCompanyAnalysis] = useState(null); + const [isAnalysisLoading, setIsAnalysisLoading] = useState(true); // const [techSummary, setTechSummary] = useState(null); // 기술적 분석을 로딩 중에도 렌더링하기 위한 기본 스톡 값 @@ -106,7 +107,9 @@ export default function StockDetailPage() { }; // 섹션별 로딩 상태 + const [isHeaderLoading, setIsHeaderLoading] = useState(true); const [isChartLoading, setIsChartLoading] = useState(true); + const [isTechnicalLoading, setIsTechnicalLoading] = useState(true); const isNewsLoading = news.length === 0; const isFinancialLoading = financialData == null; @@ -127,6 +130,9 @@ export default function StockDetailPage() { const run = async () => { try { if (!code) return; + + // 1단계: Stock Header 데이터 로딩 + console.log("1단계: Stock Header 로딩 시작"); const [priceInfo, companyInfo] = await Promise.all([ getStockInfoByCode(code), getCompanyInfoByCode(code), @@ -184,76 +190,14 @@ export default function StockDetailPage() { prediction: { targetPrice: 0, confidence: 0, recommendation: "hold" }, }; - if (!cancelled) setStock(mapped); - - // LLM 종합 분석 요청 - try { - const totalAnalysis = await getTotalAnalysis(code); - console.log("Total Analysis Response:", totalAnalysis); - - // 기술적/재무 데이터 매핑 - if (!cancelled && companyInfo) { - const mergedAnalysis = { - ...totalAnalysis, - company_analysis: totalAnalysis.company_analysis, // API에서 받은 재무 분석 텍스트 - company_data: companyInfo.company_analysis ?? companyInfo, // 기업 재무 데이터 - main_analysis: totalAnalysis.main_analysis, - volume_analysis: totalAnalysis.volume_analysis, - volatility_analysis: totalAnalysis.volatility_analysis, - combined_technical_analysis: - totalAnalysis.combined_technical_analysis, - fin_total_analysis: totalAnalysis.fin_total_analysis, - // 기존 기술적 지표 데이터도 포함 - main_analysis_data: companyInfo.main_analysis, - volatility_analysis_data: companyInfo.volatility_analysis, - volume_analysis_data: companyInfo.volume_analysis, - }; - setCompanyAnalysis(mergedAnalysis); - setFinancialData({ - revenue: - (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, - netIncome: - (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, - debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, - roe: companyInfo.company_analysis?.ROE ?? 0, - per: companyInfo.company_analysis?.PER ?? 0, - pbr: companyInfo.company_analysis?.PBR ?? 0, - dividendYield: 0, - }); - } - } catch (error) { - console.error("Total Analysis API Error:", error); - // API 실패 시에도 기본 분석 데이터 설정 - if (!cancelled && companyInfo) { - const fallbackAnalysis = { - company_analysis: "재무 분석 정보를 불러오고 있습니다.", - company_data: companyInfo.company_analysis ?? companyInfo, - main_analysis: "분석 정보를 불러오고 있습니다.", - volume_analysis: "분석 정보를 불러오고 있습니다.", - volatility_analysis: "분석 정보를 불러오고 있습니다.", - combined_technical_analysis: "분석 정보를 불러오고 있습니다.", - fin_total_analysis: "분석 정보를 불러오고 있습니다.", - // 기존 기술적 지표 데이터도 포함 - main_analysis_data: companyInfo.main_analysis, - volatility_analysis_data: companyInfo.volatility_analysis, - volume_analysis_data: companyInfo.volume_analysis, - }; - setCompanyAnalysis(fallbackAnalysis); - setFinancialData({ - revenue: - (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, - netIncome: - (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, - debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, - roe: companyInfo.company_analysis?.ROE ?? 0, - per: companyInfo.company_analysis?.PER ?? 0, - pbr: companyInfo.company_analysis?.PBR ?? 0, - dividendYield: 0, - }); - } + if (!cancelled) { + setStock(mapped); + setIsHeaderLoading(false); + console.log("1단계: Stock Header 로딩 완료"); } - // 차트, 뉴스 + // 2단계: 차트 데이터 로딩 + console.log("2단계: 차트 데이터 로딩 시작"); const chartRes = await getStockChart(code, "D", 180); const chartData: HistoricalData[] = chartRes.candles.map((c) => ({ date: c.date, @@ -264,6 +208,46 @@ export default function StockDetailPage() { volume: c.volume, })); + if (!cancelled) { + setHistoricalData(chartData); + setIsChartLoading(false); + console.log("2단계: 차트 데이터 로딩 완료"); + } + + // 3단계: 기술적 분석 데이터 로딩 + console.log("3단계: 기술적 분석 데이터 로딩 시작"); + if (!cancelled && companyInfo) { + const initialAnalysis = { + company_analysis: "재무 분석 정보를 불러오고 있습니다.", + company_data: companyInfo.company_analysis ?? companyInfo, + main_analysis: "분석 정보를 불러오고 있습니다.", + volume_analysis: "분석 정보를 불러오고 있습니다.", + volatility_analysis: "분석 정보를 불러오고 있습니다.", + combined_technical_analysis: "분석 정보를 불러오고 있습니다.", + fin_total_analysis: "분석 정보를 불러오고 있습니다.", + // 기존 기술적 지표 데이터도 포함 + main_analysis_data: companyInfo.main_analysis, + volatility_analysis_data: companyInfo.volatility_analysis, + volume_analysis_data: companyInfo.volume_analysis, + }; + setCompanyAnalysis(initialAnalysis); + setFinancialData({ + revenue: + (companyInfo.company_analysis?.["매출액"] ?? 0) * 1_000_000_000, + netIncome: + (companyInfo.company_analysis?.["순이익"] ?? 0) * 1_000_000_000, + debtRatio: companyInfo.company_analysis?.["부채비율"] ?? 0, + roe: companyInfo.company_analysis?.ROE ?? 0, + per: companyInfo.company_analysis?.PER ?? 0, + pbr: companyInfo.company_analysis?.PBR ?? 0, + dividendYield: 0, + }); + setIsTechnicalLoading(false); + console.log("3단계: 기술적 분석 데이터 로딩 완료"); + } + + // 4단계: 뉴스 데이터 로딩 (백그라운드) + console.log("4단계: 뉴스 데이터 로딩 시작"); const companyNews = await getNewsByCompany(code, 10); const mappedNews: NewsListItem[] = companyNews.map((n) => ({ id: n.id, @@ -273,22 +257,49 @@ export default function StockDetailPage() { sentiment: (n.impact as any) ?? "neutral", })); - const mockFinancialData: FinancialData = { - revenue: 279600000000000, - netIncome: 15400000000000, - debtRatio: 23.4, - roe: 15.8, - per: 12.3, - pbr: 1.2, - dividendYield: 2.1, - }; - if (!cancelled) { - setHistoricalData(chartData); setNews(mappedNews); - setIsChartLoading(false); - if (!financialData) setFinancialData(mockFinancialData); + console.log("4단계: 뉴스 데이터 로딩 완료"); } + + // 5단계: LLM 종합 분석 요청 (비동기로 별도 처리) + const loadAnalysis = async () => { + try { + console.log("5단계: LLM 분석 로딩 시작"); + const totalAnalysis = await getTotalAnalysis(code); + console.log("Total Analysis Response:", totalAnalysis); + + if (!cancelled && companyInfo) { + const mergedAnalysis = { + ...totalAnalysis, + company_analysis: totalAnalysis.company_analysis, // API에서 받은 재무 분석 텍스트 + company_data: companyInfo.company_analysis ?? companyInfo, // 기업 재무 데이터 + main_analysis: totalAnalysis.main_analysis, + volume_analysis: totalAnalysis.volume_analysis, + volatility_analysis: totalAnalysis.volatility_analysis, + combined_technical_analysis: + totalAnalysis.combined_technical_analysis, + fin_total_analysis: totalAnalysis.fin_total_analysis, + // 기존 기술적 지표 데이터도 포함 + main_analysis_data: companyInfo.main_analysis, + volatility_analysis_data: companyInfo.volatility_analysis, + volume_analysis_data: companyInfo.volume_analysis, + }; + setCompanyAnalysis(mergedAnalysis); + console.log("5단계: LLM 분석 로딩 완료"); + } + } catch (error) { + console.error("Total Analysis API Error:", error); + // API 실패 시에도 기본 분석 데이터는 유지 + } finally { + if (!cancelled) { + setIsAnalysisLoading(false); + } + } + }; + + // LLM 분석을 별도로 실행 + loadAnalysis(); } catch { // 실패 시에도 목데이터로 모든 섹션 채워서 UI가 비지 않도록 처리 if (!cancelled) { @@ -309,6 +320,7 @@ export default function StockDetailPage() { }, } as StockDetail; setStock(fallback); + setIsHeaderLoading(false); const mockHistoricalData: HistoricalData[] = Array.from( { length: 30 }, @@ -356,6 +368,7 @@ export default function StockDetailPage() { dividendYield: 2.1, }; setFinancialData(mockFinancialData); + setIsTechnicalLoading(false); } } }; @@ -409,6 +422,8 @@ export default function StockDetailPage() { stock={stock ?? placeholderStock} historicalData={historicalData} analysis={companyAnalysis} + isAnalysisLoading={isAnalysisLoading} + isTechnicalLoading={isTechnicalLoading} />
@@ -474,6 +489,7 @@ export default function StockDetailPage() { )}
From 7e56e5c7532756b2f280f8d7b024585d5c83377c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Thu, 4 Sep 2025 13:48:28 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=8E=A8:=20=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=84=20=ED=95=84=ED=84=B0=EB=A7=81=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/stock/detail.ts | 3 +- .../stockDetail/chart/PriceVolumeChart.tsx | 143 +++++++++++++----- .../stockDetail/chart/StockChartHeader.tsx | 59 +++++--- src/components/stockDetail/chart/index.tsx | 86 ++++++++--- src/pages/stock/StockDetailPage.tsx | 47 +++--- 5 files changed, 235 insertions(+), 103 deletions(-) diff --git a/src/api/stock/detail.ts b/src/api/stock/detail.ts index ffe7b1b..4115980 100644 --- a/src/api/stock/detail.ts +++ b/src/api/stock/detail.ts @@ -56,8 +56,7 @@ export async function getTotalAnalysis( stockCode: string ): Promise { const { data } = await axiosInstance.get( - `/api/info/total_analysis/${encodeURIComponent(stockCode)}`, - { timeout: 60000 } // LLM 분석은 60초 타임아웃 + `/api/info/total_analysis/${encodeURIComponent(stockCode)}` ); return data; } diff --git a/src/components/stockDetail/chart/PriceVolumeChart.tsx b/src/components/stockDetail/chart/PriceVolumeChart.tsx index dea63a2..1e3c1ca 100644 --- a/src/components/stockDetail/chart/PriceVolumeChart.tsx +++ b/src/components/stockDetail/chart/PriceVolumeChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, memo } from "react"; import Highcharts from "highcharts/highstock"; import HighchartsReact from "highcharts-react-official"; import { TimeRangePT } from "."; @@ -12,38 +12,40 @@ export interface PriceVolumeChartProps { showMA: Record<"ma5" | "ma20" | "ma60" | "ma120", boolean>; } -export default function PriceVolumeChart({ +const PriceVolumeChart = memo(function PriceVolumeChart({ data, timeRange, showMA, }: PriceVolumeChartProps) { - // 초기부터 렌더링하여 차트가 안 보이는 문제 방지 - const [ready, setReady] = useState(true); + // TradingView 차트 우선 사용 + const [ready, setReady] = useState(false); const tvContainerRef = useRef(null); const [tvReady, setTvReady] = useState(false); + const [tvFailed, setTvFailed] = useState(false); - // Annotations 모듈 안전 로딩 (환경별 export 방식 대응) + // TradingView 실패 시에만 Highcharts 모듈 로딩 useEffect(() => { - (async () => { - try { - const mod: any = await import("highcharts/modules/annotations"); - const initFn = - typeof mod === "function" - ? mod - : typeof mod?.default === "function" - ? mod.default - : null; - if (initFn) { - initFn(Highcharts); + if (tvFailed) { + (async () => { + try { + const mod: any = await import("highcharts/modules/annotations"); + const initFn = + typeof mod === "function" + ? mod + : typeof mod?.default === "function" + ? mod.default + : null; + if (initFn) { + initFn(Highcharts); + } + } catch (_) { + // 실패해도 치명적이지 않음 + } finally { + setReady(true); } - } catch (_) { - // 실패해도 치명적이지 않음 - } finally { - // 모듈 로딩 실패/성공과 무관하게 이미 렌더링 중 - setReady(true); - } - })(); - }, []); + })(); + } + }, [tvFailed]); // TradingView Lightweight Charts 렌더링 (가능하면 우선 사용) useEffect(() => { @@ -55,6 +57,14 @@ export default function PriceVolumeChart({ let ma60Series: any | null = null; let ma120Series: any | null = null; + // TradingView 로딩 타임아웃 (3초) + const timeoutId = setTimeout(() => { + if (!tvReady && !tvFailed) { + console.log("TradingView 차트 로딩 타임아웃, Highcharts로 폴백"); + setTvFailed(true); + } + }, 3000); + (async () => { if (!tvContainerRef.current) return; try { @@ -161,19 +171,25 @@ export default function PriceVolumeChart({ chart.timeScale().fitContent(); setTvReady(true); - } catch { + setTvFailed(false); + clearTimeout(timeoutId); // 성공 시 타임아웃 클리어 + } catch (error) { + console.error("TradingView 차트 로딩 실패:", error); setTvReady(false); + setTvFailed(true); + clearTimeout(timeoutId); // 실패 시 타임아웃 클리어 } })(); return () => { + clearTimeout(timeoutId); // 컴포넌트 언마운트 시 타임아웃 클리어 if (chart && tvContainerRef.current) { try { chart.remove?.(); } catch {} } }; - }, [data, showMA]); + }, [data, showMA, tvReady, tvFailed]); // timeRange 에 따른 데이터 필터링 및 Point 생성 const { @@ -274,28 +290,36 @@ export default function PriceVolumeChart({ }; }, [data, timeRange]); - // 3) 차트 옵션 + // 3) 차트 옵션 (최적화) const options: Highcharts.Options = useMemo(() => { return { chart: { zoomType: "x", backgroundColor: "#ffffff", height: 500, + animation: { + duration: 500, // 부드러운 전환을 위한 애니메이션 + easing: "easeInOutCubic", + }, + reflow: false, // 리플로우 비활성화 }, accessibility: { enabled: false }, title: { text: "" }, xAxis: { type: "datetime", - crosshair: true, + crosshair: false, // 크로스헤어 비활성화로 성능 향상 gridLineWidth: 1, gridLineColor: "#f1f5f9", + labels: { + step: Math.max(1, Math.floor(ohlcData.length / 10)), // 라벨 수 줄이기 + }, }, yAxis: [ { title: { text: "" }, height: "65%", lineWidth: 2, - crosshair: true, + crosshair: false, // 크로스헤어 비활성화 opposite: true, gridLineColor: "#eef2f7", labels: { style: { color: "#475569" } }, @@ -314,6 +338,7 @@ export default function PriceVolumeChart({ tooltip: { shared: false, split: true, + animation: false, // 툴팁 애니메이션 비활성화 }, series: [ { @@ -328,6 +353,11 @@ export default function PriceVolumeChart({ upLineColor: "#16a34a", pointPadding: 0, dataGrouping: { enabled: false }, + animation: { + duration: 500, + easing: "easeInOutCubic", + }, // 시리즈 애니메이션 활성화 + enableMouseTracking: true, }, { type: "column", @@ -393,18 +423,49 @@ export default function PriceVolumeChart({ lastClose, ]); - if (!ready) { - return
; + // TradingView 차트 우선 렌더링 + if (tvReady) { + return ( +
+ ); } - return tvReady ? ( -
- ) : ( - + // TradingView 실패 시 Highcharts 사용 + if (tvFailed && ready) { + return ( +
+ +
+ ); + } + + // TradingView 로딩 중이지만 데이터가 있으면 Highcharts로 폴백 + if (data.length > 0 && !tvReady && !tvFailed) { + return ( +
+ +
+ ); + } + + // 로딩 중 + return ( +
); -} +}); + +export default PriceVolumeChart; diff --git a/src/components/stockDetail/chart/StockChartHeader.tsx b/src/components/stockDetail/chart/StockChartHeader.tsx index 7ddcfdb..9838448 100644 --- a/src/components/stockDetail/chart/StockChartHeader.tsx +++ b/src/components/stockDetail/chart/StockChartHeader.tsx @@ -1,13 +1,18 @@ -import { dayTable, miniteTable } from '@/config/chart'; -import { TimeRangePT } from '.'; +import { dayTable, miniteTable } from "@/config/chart"; +import { TimeRangePT } from "."; interface StockChartHeader { timeRange: TimeRangePT; onTimeRangeChange: (r: TimeRangePT) => void; - showMA: Record<'ma5' | 'ma20' | 'ma60' | 'ma120', boolean>; - onToggleMA: (maType: 'ma5' | 'ma20' | 'ma60' | 'ma120') => void; + showMA: Record<"ma5" | "ma20" | "ma60" | "ma120", boolean>; + onToggleMA: (maType: "ma5" | "ma20" | "ma60" | "ma120") => void; } -export default function StockChartHeader({ showMA, timeRange, onTimeRangeChange, onToggleMA }: StockChartHeader) { +export default function StockChartHeader({ + showMA, + timeRange, + onTimeRangeChange, + onToggleMA, +}: StockChartHeader) { return (
@@ -17,33 +22,41 @@ export default function StockChartHeader({ showMA, timeRange, onTimeRangeChange, {/* 이동평균선 토글 버튼들 */}