From e01aa99069b5c0d65b0274a5d30f4f0b12701710 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 15 Apr 2026 14:27:01 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=AC=EB=B3=BC=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=84=EC=82=B0=EA=B0=92/API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99/UX=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/character/symbols 엔드포인트: Nexon API의 symbol-equipment를 (type, region, level, growth, force) 구조로 정제 후 반환 - 프론트: useQueries로 각 캐릭터 심볼 자동 로드, 새로고침마다 갱신, syncCharacterSymbols로 store의 progress에 병합 - equipped 판정을 store 기반으로 전환 - 남은 심볼/필요 메소/체납 메소 실제 계산, 만렙 시 '-' 표시 - 성장치 라벨 현재 레벨 기준 표시, 만렙 시 MAX/amber 색상 + 퍼센트 숨김 - 일퀘/주간퀘/추가 심볼 비활성화 및 완료 토글 숨김 (만렙) - 하단 요약 누적 체납/남은 필요 메소 실제 합산, 라벨 색상 통일 - 메소 값 호버 시 '억/만' 한글 축약 툴팁 - Select 비활성 상태에서 금지 커서 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/routes/character.js | 31 ++++ frontend/src/components/Select.jsx | 2 +- frontend/src/features/symbol/Symbol.jsx | 202 +++++++++++++++++++----- frontend/src/features/symbol/store.js | 25 +++ 4 files changed, 221 insertions(+), 39 deletions(-) diff --git a/backend/routes/character.js b/backend/routes/character.js index 3ffe994..e9451f0 100644 --- a/backend/routes/character.js +++ b/backend/routes/character.js @@ -39,4 +39,35 @@ router.get('/search', async (req, res) => { } }); +// OCID로 장착 심볼 조회 +router.get('/symbols', async (req, res) => { + const { ocid } = req.query; + if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' }); + + try { + const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, { + params: { ocid }, + headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, + }); + + const parsed = (data.symbol || []).map((s) => { + const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim()); + const type = prefix?.replace(/심볼$/, '').trim(); // '아케인심볼' → '아케인' + return { + type, + region, + level: Number(s.symbol_level) || 0, + force: Number(s.symbol_force) || 0, + growth_count: Number(s.symbol_growth_count) || 0, + require_growth_count: Number(s.symbol_require_growth_count) || 0, + }; + }); + + res.json({ ocid, character_class: data.character_class, symbols: parsed }); + } catch (err) { + console.error('심볼 조회 오류:', err.response?.data || err.message); + res.status(500).json({ error: '심볼 조회 실패' }); + } +}); + export default router; diff --git a/frontend/src/components/Select.jsx b/frontend/src/components/Select.jsx index bf4d3d1..ae71e59 100644 --- a/frontend/src/components/Select.jsx +++ b/frontend/src/components/Select.jsx @@ -105,7 +105,7 @@ export default function Select({ value, onChange, options, disabled, className = onClick={() => !disabled && setOpen((v) => !v)} className={`w-full flex items-center justify-between gap-2 rounded-lg border bg-gray-950 px-3 py-2 text-sm transition outline-none ${ open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20' - } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`} + } ${disabled ? 'opacity-50 !cursor-default' : ''}`} > {selected ? selected.label : placeholder} diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx index f3d544e..d836294 100644 --- a/frontend/src/features/symbol/Symbol.jsx +++ b/frontend/src/features/symbol/Symbol.jsx @@ -1,10 +1,22 @@ import { useState, useEffect, useMemo } from 'react' -import { useQuery, useMutation } from '@tanstack/react-query' +import { useQuery, useQueries, useMutation } from '@tanstack/react-query' import { api } from '../../api/client' import { useLayout } from '../../components/Layout' import Select from '../../components/Select' +import Tooltip from '../../components/Tooltip' import { useSymbolStore } from './store' +function formatMesoKorean(n) { + const v = Number(n) || 0 + if (v <= 0) return '0' + const eok = Math.floor(v / 100_000_000) + const man = Math.floor((v % 100_000_000) / 10_000) + const parts = [] + if (eok) parts.push(`${eok.toLocaleString()}억`) + if (man) parts.push(`${man.toLocaleString()}만`) + return parts.length ? parts.join(' ') : v.toLocaleString() +} + const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱'] function CharacterCard({ char, active, onSelect, onRemove }) { @@ -67,12 +79,32 @@ function SymbolCard({ symbol, equipped, charId }) { const extra = progress?.extra ?? 0 const patch = (p) => charId && updateSymbol(charId, symbol.id, p) - // 임시 목업 값 (계산 기능 미구현) const level = progress?.level ?? 0 const growth = progress?.growth ?? 0 - const requireGrowth = symbol.levels?.[0]?.required_count || 0 - const remainingSymbols = '-' - const remainingMeso = '-' + const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0 + const isMax = equipped && level >= symbol.max_level + const interactable = equipped && !isMax + + // 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감) + // 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합 + // 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소 + const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => { + if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 } + let sym = 0, meso = 0, arr = 0 + for (const l of symbol.levels) { + if (l.level < level) continue + if (l.level === level) { + sym += Math.max(l.required_count - growth, 0) + meso += l.meso_cost + if (growth >= l.required_count) arr += l.meso_cost + } else { + sym += l.required_count + meso += l.meso_cost + } + } + return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr } + }, [equipped, level, growth, symbol.levels]) + const daysLeft = '-' const completeDate = '-' @@ -100,31 +132,43 @@ function SymbolCard({ symbol, equipped, charId }) { / {symbol.max_level} - + {!isMax && ( + + )} {/* 진행도 바 */}
-
- 성장치 {growth} / {requireGrowth} - {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}% +
+ + 성장치 {isMax ? ( + MAX + ) : ( + <>{growth} / {requireGrowth} + )} + + {!isMax && ( + + {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}% + + )}
@@ -141,7 +185,7 @@ function SymbolCard({ symbol, equipped, charId }) { inputMode="numeric" value={equipped ? String(daily) : '0'} onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} - disabled={!equipped} + disabled={!interactable} className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition" />
@@ -155,7 +199,7 @@ function SymbolCard({ symbol, equipped, charId }) { value: n, label: `${n * symbol.weekly_default}개`, }))} - disabled={!equipped} + disabled={!interactable} />
)} @@ -166,7 +210,7 @@ function SymbolCard({ symbol, equipped, charId }) { inputMode="numeric" value={equipped ? String(extra) : '0'} onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} - disabled={!equipped} + disabled={!interactable} className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition" /> @@ -176,15 +220,33 @@ function SymbolCard({ symbol, equipped, charId }) {
남은 심볼 - {remainingSymbols} + + {equipped && !isMax ? `${remainingSymbols.toLocaleString()}개` : '-'} +
필요 메소 - {remainingMeso} + {equipped && !isMax ? ( + + + {remainingMeso.toLocaleString()} + + + ) : ( + - + )}
체납 메소 - - + {equipped && !isMax ? ( + + + {arrearMeso.toLocaleString()} + + + ) : ( + - + )}
남은 일수 @@ -234,6 +296,42 @@ export default function Symbol() { const addCharacter = useSymbolStore((s) => s.addCharacter) const removeCharacter = useSymbolStore((s) => s.removeCharacter) const selectCharacter = useSymbolStore((s) => s.selectCharacter) + const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols) + + // 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신) + const symbolQueries = useQueries({ + queries: characters.map((c) => ({ + queryKey: ['character', 'symbols', c.id], + queryFn: () => api(`/api/character/symbols?ocid=${c.id}`), + enabled: !!c.id, + refetchOnMount: 'always', + staleTime: 0, + })), + }) + + // symbolQueries 결과를 store로 반영 + useEffect(() => { + if (!allSymbols.length || !characters.length) return + // (type, region) → symbol id 매핑 + const lookup = {} + for (const s of allSymbols) lookup[`${s.type}|${s.region}`] = s + characters.forEach((c, idx) => { + const q = symbolQueries[idx] + if (!q?.data?.symbols) return + const equippedMap = {} + for (const es of q.data.symbols) { + const match = lookup[`${es.type}|${es.region}`] + if (!match) continue + equippedMap[match.id] = { + level: es.level, + growth: es.growth_count, + require_growth: es.require_growth_count, + } + } + syncCharacterSymbols(c.id, equippedMap) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')]) const [addName, setAddName] = useState('') const [addError, setAddError] = useState('') @@ -263,8 +361,28 @@ export default function Symbol() { searchMutation.mutate(n) } - // 임시: 첫 번째 심볼만 장착된 것으로 표시 - const isEquipped = (i) => i === 0 + const progress = useSymbolStore((s) => s.progress[selectedCharId]) + const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped + + // 현재 탭의 누적 메소 계산 + const { totalRequiredMeso, totalArrearMeso } = useMemo(() => { + let req = 0, arr = 0 + for (const s of symbols) { + const p = progress?.[s.id] + if (!p?.equipped) continue + if (p.level >= s.max_level) continue + for (const l of s.levels || []) { + if (l.level < p.level) continue + if (l.level === p.level) { + req += l.meso_cost + if ((p.growth || 0) >= l.required_count) arr += l.meso_cost + } else { + req += l.meso_cost + } + } + } + return { totalRequiredMeso: req, totalArrearMeso: arr } + }, [symbols, progress]) return (
@@ -337,26 +455,34 @@ export default function Symbol() { {/* 심볼 카드 그리드 */}
- {symbols.map((s, i) => ( - + {symbols.map((s) => ( + ))}
{/* 전체 요약 */}
-
{tabInfo?.label} 전체 만렙 완료 예상일
-
2026년 09월 12일 (토)
+
{tabInfo?.label} 전체 만렙 완료 예상일
+
-
누적 체납 메소
-
108,000,000
+ +
+ {totalArrearMeso.toLocaleString()} +
+
-
누적 필요 메소
-
768,000,000
+
남은 필요 메소
+ +
+ {totalRequiredMeso.toLocaleString()} +
+
diff --git a/frontend/src/features/symbol/store.js b/frontend/src/features/symbol/store.js index 7705c5b..be43f68 100644 --- a/frontend/src/features/symbol/store.js +++ b/frontend/src/features/symbol/store.js @@ -70,6 +70,31 @@ export const useSymbolStore = create(persist( delete next[charId] return { progress: next } }), + + /** + * API 응답을 store에 반영. + * equippedMap: { [symbolId]: { level, growth, require_growth } } + * - API에 있는 심볼: equipped=true, level/growth 갱신 (사용자 입력값인 daily/weeklyCount/extra/dailyDone은 유지) + * - API에 없는 심볼: equipped=false로 마킹 + */ + syncCharacterSymbols: (charId, equippedMap) => set((s) => { + const charProg = { ...(s.progress[charId] || {}) } + // 기존 equipped를 false로 초기화 + for (const k of Object.keys(charProg)) { + charProg[k] = { ...charProg[k], equipped: false } + } + // 새 장착 정보 병합 + for (const [sid, info] of Object.entries(equippedMap)) { + charProg[sid] = { + ...(charProg[sid] || {}), + equipped: true, + level: info.level, + growth: info.growth, + require_growth: info.require_growth, + } + } + return { progress: { ...s.progress, [charId]: charProg } } + }), }), { name: 'maple-symbol',