From 1fe3ba0d12546cf3e4f2f5ffa90a09bf6f66c774 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 19 Apr 2026 11:43:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=203?= =?UTF-8?q?=EB=8B=A8=EA=B3=84:=20Symbol.jsx=20=EB=B6=84=EB=A6=AC=20(717=20?= =?UTF-8?q?=E2=86=92=20346=20=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - features/symbol/utils.js: formatKoreanDate, computeCompletion, TYPE_ORDER - features/symbol/pc/user/CharacterCard.jsx: 캐릭터 카드 (memo) - features/symbol/pc/user/SymbolCard.jsx: 심볼 카드 (memo, 계산 로직 포함) - Symbol.jsx: 검색/탭/그리드/요약 렌더링만 담당 - basicQueries/symbolQueries 배열을 useMemo로 감쌈 (매 렌더 재생성 방지) Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/features/symbol/pc/Symbol.jsx | 395 +----------------- .../features/symbol/pc/user/CharacterCard.jsx | 57 +++ .../features/symbol/pc/user/SymbolCard.jsx | 250 +++++++++++ frontend/src/features/symbol/utils.js | 38 ++ 4 files changed, 362 insertions(+), 378 deletions(-) create mode 100644 frontend/src/features/symbol/pc/user/CharacterCard.jsx create mode 100644 frontend/src/features/symbol/pc/user/SymbolCard.jsx create mode 100644 frontend/src/features/symbol/utils.js diff --git a/frontend/src/features/symbol/pc/Symbol.jsx b/frontend/src/features/symbol/pc/Symbol.jsx index 9b57089..ba620b7 100644 --- a/frontend/src/features/symbol/pc/Symbol.jsx +++ b/frontend/src/features/symbol/pc/Symbol.jsx @@ -1,363 +1,14 @@ -import { memo, useState, useEffect, useLayoutEffect, useMemo } from 'react' +import { useState, useEffect, useLayoutEffect, useMemo } from 'react' import { useQuery, useQueries, useMutation } from '@tanstack/react-query' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import timezone from 'dayjs/plugin/timezone' import { api } from '../../../api/client' import { useLayout } from '../../../components/pc/Layout' -import Select from '../../../components/common/Select' import Tooltip from '../../../components/common/Tooltip' import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown' import { useSymbolStore } from '../store' import { formatMesoKorean } from '../../../utils/formatting' - -dayjs.extend(utc) -dayjs.extend(timezone) -const KST = 'Asia/Seoul' -const DOW = ['일', '월', '화', '수', '목', '금', '토'] - -function formatKoreanDate(d) { - const dj = dayjs(d).tz(KST) - return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})` -} - -/** - * 심볼 완료까지 남은 일수/예상 완료일 계산 - * - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정 - * - extra(추가 심볼)는 즉시 적용 - * - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급) - */ -function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) { - const need = Math.max(remainingSymbols - extra, 0) - if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() } - if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null } - - let acc = 0 - let cursor = dayjs().tz(KST).startOf('day') - for (let day = 0; day < 3650; day++) { - // 오늘은 dailyDone이면 일퀘 없음, 그 외엔 daily - if (!(day === 0 && dailyDone)) acc += daily - // 목요일(day=4)에 주간퀘 전량 지급 - if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek - if (acc >= need) return { days: day, date: cursor.toDate() } - cursor = cursor.add(1, 'day') - } - return { days: null, date: null } -} - -const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱'] - -const CharacterCard = memo(function CharacterCard({ char, active, onSelect, onRemove }) { - return ( -
{ - if (e.target.closest('button')) return - onSelect() - }} - className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none" - style={{ - borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)', - background: active ? 'var(--selected-bg)' : 'var(--surface-3)', - }} - > - - -
-
- {char.character_image ? ( - - ) : ( - ? - )} -
-
- {char.character_name} -
-
- Lv.{char.character_level} · {char.job_name} -
-
-
- ) -}) - -const SymbolCard = memo(function SymbolCard({ symbol, equipped, charId }) { - const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id]) - const updateSymbol = useSymbolStore((s) => s.updateSymbol) - - const dailyDone = progress?.dailyDone ?? false - const weeklyCount = progress?.weeklyCount ?? 3 - const daily = progress?.daily ?? symbol.daily_default - 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?.find((l) => l.level === level)?.required_count || 0 - const isMax = equipped && level >= symbol.max_level - - // 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감) - // 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합 - // 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소 - const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => { - if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 } - let sym = 0, meso = 0, arr = 0 - // 체납: 현재 성장치로 올릴 수 있는 레벨까지 누적 - let arrLv = level, arrG = growth - while (arrLv < symbol.max_level) { - const req = symbol.levels.find((l) => l.level === arrLv)?.required_count - const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost - if (req == null || cost == null || arrG < req) break - arr += cost - arrG -= req - arrLv += 1 - } - let g = growth - for (const l of symbol.levels) { - if (l.level < level) continue - sym += Math.max(l.required_count - g, 0) - g = Math.max(g - l.required_count, 0) - meso += l.meso_cost - } - return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr } - }, [equipped, level, growth, symbol.levels, symbol.max_level]) - - // 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영) - const reachableLevel = useMemo(() => { - if (!equipped || isMax) return level - let lv = level - let g = growth - while (lv < symbol.max_level) { - const req = symbol.levels?.find((l) => l.level === lv)?.required_count - if (!req || g < req) break - g -= req - lv += 1 - } - return lv - }, [equipped, isMax, level, growth, symbol.levels, symbol.max_level]) - - // 성장치로 만렙까지 도달 가능하지만 레벨업은 안 한 상태 - const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level - const interactable = equipped && !isMax && !effectivelyMax - - // 남은 일수/예상 완료일 - const { days: daysLeft, date: completeDate } = useMemo(() => { - if (!equipped || isMax) return { days: null, date: null } - return computeCompletion({ - remainingSymbols, - daily, - weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0), - extra, - dailyDone, - }) - }, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone]) - - const inputClass = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50" - - return ( -
-
-
- {symbol.image_url && ( - {symbol.region} - )} -
-
-
{symbol.region}
-
- Lv.{level} - / {symbol.max_level} -
-
- {equipped && !isMax && !effectivelyMax && ( - - )} -
- - {/* 진행도 바 */} -
-
- {isMax ? ( - - 성장치 MAX - - ) : effectivelyMax ? ( - - - 성장치 {growth} (MAX) / {requireGrowth} - - - ) : reachableLevel > level ? ( - - - 성장치 {growth} / {requireGrowth} - - - ) : ( - - 성장치 {growth} / {requireGrowth} - - )} - {!isMax && !effectivelyMax && ( - - {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}% - - )} -
-
-
-
-
- - {/* 획득량 입력 */} -
0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }} - > -
- - patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} - disabled={!interactable} - className={inputClass} - style={{ - background: 'var(--input-bg)', - borderColor: 'var(--input-border)', - color: 'var(--text-strong)', - }} - /> -
- {symbol.weekly_default > 0 && ( -
- - patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} - disabled={!interactable} - className={inputClass} - style={{ - background: 'var(--input-bg)', - borderColor: 'var(--input-border)', - color: 'var(--text-strong)', - }} - /> -
-
- - {/* 정보 */} -
- {[ - { label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' }, - { label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null }, - { label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null }, - { label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' }, - { label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true }, - ].map((row, i) => ( -
- {row.label} - {row.tooltip ? ( - - - {row.value} - - - ) : ( - - {row.value} - - )} -
- ))} -
-
- ) -}) +import { formatKoreanDate, computeCompletion, TYPE_ORDER } from '../utils' +import CharacterCard from './user/CharacterCard' +import SymbolCard from './user/SymbolCard' export default function Symbol() { const { setFullscreen } = useLayout() @@ -395,17 +46,17 @@ export default function Symbol() { const tab = storedTab || tabs[0]?.key || null const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) } - + // 각 캐릭터 기본정보(코디 이미지) 새로고침 const basicQueries = useQueries({ - queries: characters.map((c) => ({ + queries: useMemo(() => characters.map((c) => ({ queryKey: ['character', 'basic', c.character_name], queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`), enabled: !!c.character_name, refetchOnMount: 'always', staleTime: 0, retry: false, - })), + })), [characters]), }) useEffect(() => { characters.forEach((c, idx) => { @@ -425,19 +76,18 @@ export default function Symbol() { // 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신) const symbolQueries = useQueries({ - queries: characters.map((c) => ({ + queries: useMemo(() => characters.map((c) => ({ queryKey: ['character', 'symbols', c.id], queryFn: () => api(`/api/character/symbols?ocid=${c.id}`), enabled: !!c.id, refetchOnMount: 'always', staleTime: 0, - })), + })), [characters]), }) // 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) => { @@ -497,7 +147,6 @@ export default function Symbol() { const p = progress?.[s.id] if (!p?.equipped) continue if (p.level >= s.max_level) continue - // 체납 성장치로 만렙 도달 가능한지 확인 let lv = p.level, g = p.growth || 0 while (lv < s.max_level) { const r = s.levels?.find((l) => l.level === lv)?.required_count @@ -506,13 +155,12 @@ export default function Symbol() { } const effMax = lv >= s.max_level - // 체납 누적 (성장치 cascade) let arrLv = p.level, arrG = p.growth || 0 while (arrLv < s.max_level) { - const lv = s.levels?.find((x) => x.level === arrLv) - if (!lv || arrG < lv.required_count) break - arr += lv.meso_cost - arrG -= lv.required_count + const lv2 = s.levels?.find((x) => x.level === arrLv) + if (!lv2 || arrG < lv2.required_count) break + arr += lv2.meso_cost + arrG -= lv2.required_count arrLv += 1 } let remaining = 0 @@ -523,7 +171,7 @@ export default function Symbol() { gg = Math.max(gg - l.required_count, 0) req += l.meso_cost } - if (effMax) continue // 완료 예상일 계산에서 제외 + if (effMax) continue const { date } = computeCompletion({ remainingSymbols: remaining, daily: p.daily ?? s.daily_default ?? 0, @@ -669,10 +317,7 @@ export default function Symbol() {
{tabInfo?.label} 전체 만렙 완료 예상일
-
+
{overallDate ? formatKoreanDate(overallDate) : '-'}
@@ -680,10 +325,7 @@ export default function Symbol() {
누적 체납 메소
-
+
{totalArrearMeso.toLocaleString()}
@@ -692,10 +334,7 @@ export default function Symbol() {
남은 필요 메소
-
+
{totalRequiredMeso.toLocaleString()}
diff --git a/frontend/src/features/symbol/pc/user/CharacterCard.jsx b/frontend/src/features/symbol/pc/user/CharacterCard.jsx new file mode 100644 index 0000000..1d14682 --- /dev/null +++ b/frontend/src/features/symbol/pc/user/CharacterCard.jsx @@ -0,0 +1,57 @@ +import { memo } from 'react' + +function CharacterCard({ char, active, onSelect, onRemove }) { + return ( +
{ + if (e.target.closest('button')) return + onSelect() + }} + className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none" + style={{ + borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)', + background: active ? 'var(--selected-bg)' : 'var(--surface-3)', + }} + > + + +
+
+ {char.character_image ? ( + + ) : ( + ? + )} +
+
+ {char.character_name} +
+
+ Lv.{char.character_level} · {char.job_name} +
+
+
+ ) +} + +export default memo(CharacterCard) diff --git a/frontend/src/features/symbol/pc/user/SymbolCard.jsx b/frontend/src/features/symbol/pc/user/SymbolCard.jsx new file mode 100644 index 0000000..d166bee --- /dev/null +++ b/frontend/src/features/symbol/pc/user/SymbolCard.jsx @@ -0,0 +1,250 @@ +import { memo, useMemo } from 'react' +import Select from '../../../../components/common/Select' +import Tooltip from '../../../../components/common/Tooltip' +import { useSymbolStore } from '../../store' +import { formatMesoKorean } from '../../../../utils/formatting' +import { formatKoreanDate, computeCompletion } from '../../utils' + +const INPUT_CLASS = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50" +const INPUT_STYLE = { + background: 'var(--input-bg)', + borderColor: 'var(--input-border)', + color: 'var(--text-strong)', +} + +function SymbolCard({ symbol, equipped, charId }) { + const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id]) + const updateSymbol = useSymbolStore((s) => s.updateSymbol) + + const dailyDone = progress?.dailyDone ?? false + const weeklyCount = progress?.weeklyCount ?? 3 + const daily = progress?.daily ?? symbol.daily_default + 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?.find((l) => l.level === level)?.required_count || 0 + const isMax = equipped && level >= symbol.max_level + + const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => { + if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 } + let sym = 0, meso = 0, arr = 0 + let arrLv = level, arrG = growth + while (arrLv < symbol.max_level) { + const req = symbol.levels.find((l) => l.level === arrLv)?.required_count + const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost + if (req == null || cost == null || arrG < req) break + arr += cost + arrG -= req + arrLv += 1 + } + let g = growth + for (const l of symbol.levels) { + if (l.level < level) continue + sym += Math.max(l.required_count - g, 0) + g = Math.max(g - l.required_count, 0) + meso += l.meso_cost + } + return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr } + }, [equipped, level, growth, symbol.levels, symbol.max_level]) + + const reachableLevel = useMemo(() => { + if (!equipped || isMax) return level + let lv = level + let g = growth + while (lv < symbol.max_level) { + const req = symbol.levels?.find((l) => l.level === lv)?.required_count + if (!req || g < req) break + g -= req + lv += 1 + } + return lv + }, [equipped, isMax, level, growth, symbol.levels, symbol.max_level]) + + const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level + const interactable = equipped && !isMax && !effectivelyMax + + const { days: daysLeft, date: completeDate } = useMemo(() => { + if (!equipped || isMax) return { days: null, date: null } + return computeCompletion({ + remainingSymbols, + daily, + weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0), + extra, + dailyDone, + }) + }, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone]) + + return ( +
+
+
+ {symbol.image_url && ( + {symbol.region} + )} +
+
+
{symbol.region}
+
+ Lv.{level} + / {symbol.max_level} +
+
+ {equipped && !isMax && !effectivelyMax && ( + + )} +
+ + {/* 진행도 바 */} +
+
+ {isMax ? ( + + 성장치 MAX + + ) : effectivelyMax ? ( + + + 성장치 {growth} (MAX) / {requireGrowth} + + + ) : reachableLevel > level ? ( + + + 성장치 {growth} / {requireGrowth} + + + ) : ( + + 성장치 {growth} / {requireGrowth} + + )} + {!isMax && !effectivelyMax && ( + + {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}% + + )} +
+
+
+
+
+ + {/* 획득량 입력 */} +
0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }} + > +
+ + patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} + disabled={!interactable} + className={INPUT_CLASS} + style={INPUT_STYLE} + /> +
+ {symbol.weekly_default > 0 && ( +
+ + patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} + disabled={!interactable} + className={INPUT_CLASS} + style={INPUT_STYLE} + /> +
+
+ + {/* 정보 */} +
+ {[ + { label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' }, + { label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null }, + { label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null }, + { label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' }, + { label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true }, + ].map((row) => ( +
+ {row.label} + {row.tooltip ? ( + + + {row.value} + + + ) : ( + + {row.value} + + )} +
+ ))} +
+
+ ) +} + +export default memo(SymbolCard) diff --git a/frontend/src/features/symbol/utils.js b/frontend/src/features/symbol/utils.js new file mode 100644 index 0000000..6eaf3a4 --- /dev/null +++ b/frontend/src/features/symbol/utils.js @@ -0,0 +1,38 @@ +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' + +dayjs.extend(utc) +dayjs.extend(timezone) + +export const KST = 'Asia/Seoul' +const DOW = ['일', '월', '화', '수', '목', '금', '토'] + +export function formatKoreanDate(d) { + const dj = dayjs(d).tz(KST) + return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})` +} + +/** + * 심볼 완료까지 남은 일수/예상 완료일 계산 + * - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정 + * - extra(추가 심볼)는 즉시 적용 + * - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급) + */ +export function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) { + const need = Math.max(remainingSymbols - extra, 0) + if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() } + if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null } + + let acc = 0 + let cursor = dayjs().tz(KST).startOf('day') + for (let day = 0; day < 3650; day++) { + if (!(day === 0 && dailyDone)) acc += daily + if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek + if (acc >= need) return { days: day, date: cursor.toDate() } + cursor = cursor.add(1, 'day') + } + return { days: null, date: null } +} + +export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']