feat: 전적검색 화면, 관리자 화면 추가

This commit is contained in:
2026-06-13 03:34:23 +09:00
parent 870615219b
commit fc0352cfcb
71 changed files with 3517 additions and 676 deletions
+71 -65
View File
@@ -8,10 +8,12 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"apexcharts": "^5.15.0",
"axios": "^1.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"vue3-apexcharts": "^1.11.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
@@ -697,35 +699,38 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1275,9 +1280,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1295,9 +1297,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1315,9 +1314,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1335,9 +1331,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1355,9 +1348,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1375,9 +1365,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1395,9 +1382,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1415,9 +1399,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1606,9 +1587,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1626,9 +1604,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1646,9 +1621,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1666,9 +1638,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1686,9 +1655,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1706,9 +1672,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1754,6 +1717,40 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
@@ -2765,6 +2762,12 @@
"node": ">=14"
}
},
"node_modules/apexcharts": {
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.0.tgz",
"integrity": "sha512-ATswoiZi8wynKcwqf0eyE0Is5mBQoDpxZN3PlSVy41OrD+9GMBp2kZQRjyGK+5/j0GFjHkuAd7GKOrt5VMoPrw==",
"license": "SEE LICENSE IN LICENSE"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -4736,9 +4739,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4760,9 +4760,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4784,9 +4781,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4808,9 +4802,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6849,6 +6840,21 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue3-apexcharts": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
"license": "see LICENSE in LICENSE",
"peerDependencies": {
"apexcharts": ">=5.10.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"apexcharts": {
"optional": false
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+3 -1
View File
@@ -14,10 +14,12 @@
"format": "oxfmt src/"
},
"dependencies": {
"apexcharts": "^5.15.0",
"axios": "^1.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"vue3-apexcharts": "^1.11.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+5
View File
@@ -6,6 +6,10 @@ import AppNav from '@/shared/components/layout/AppNav.vue'
import AppFooter from '@/shared/components/layout/AppFooter.vue'
import FeedbackWidget from '@/shared/components/FeedbackWidget.vue'
import LoginModal from '@/shared/components/LoginModal.vue'
import DevSyncButton from '@/shared/components/DevSyncButton.vue'
// 개발 환경에서만 매치 동기화 수동 트리거 버튼 노출
const isDev = import.meta.env.DEV
</script>
<template>
@@ -20,6 +24,7 @@ import LoginModal from '@/shared/components/LoginModal.vue'
<!-- 전역 오버레이 -->
<FeedbackWidget />
<LoginModal />
<DevSyncButton v-if="isDev" />
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
import { useApi } from './useApi'
// 서든어택 맵 (백엔드 MapResponseDto) — 5v5/3v3 구분은 시즌 설정에서 정한다
export interface SaMap {
id: number
name: string
}
// 맵 등록/수정 입력 (id 제외)
export type MapInput = Omit<SaMap, 'id'>
export function useSaMap() {
const api = useApi()
// 등록된 맵 목록 (모드→이름 순). 실패 시 빈 배열.
async function getMaps(): Promise<SaMap[]> {
try {
return (await api.get('/sa-maps')) as unknown as SaMap[]
} catch {
return []
}
}
// [관리자] 맵 등록. 실패 시 null (인터셉터가 에러 알림 처리).
async function createMap(input: MapInput): Promise<SaMap | null> {
try {
return (await api.post('/sa-maps', input)) as unknown as SaMap
} catch {
return null
}
}
// [관리자] 맵 수정. 실패 시 null.
async function updateMap(
id: number,
input: Partial<MapInput>,
): Promise<SaMap | null> {
try {
return (await api.patch(`/sa-maps/${id}`, input)) as unknown as SaMap
} catch {
return null
}
}
// [관리자] 맵 삭제. 성공 시 true.
async function deleteMap(id: number): Promise<boolean> {
try {
await api.delete(`/sa-maps/${id}`)
return true
} catch {
return false
}
}
return { getMaps, createMap, updateMap, deleteMap }
}
+44 -1
View File
@@ -38,6 +38,15 @@ export interface SaMatchDetail {
participants: SaParticipant[]
}
// [개발 전용] 매치 데이터 적재 현황 (백엔드 DevStatsDto)
export interface DevStats {
users: number
matches: number
details: number
participants: number
failures: number
}
// 매치 타입 (넥슨 match_type 과 동일 문자열)
export const MATCH_TYPES = [
'랭크전 솔로',
@@ -74,5 +83,39 @@ export function useSaMatch() {
}
}
return { getMatches, getMatchDetail }
// 보이는 매치들의 맵 이름 일괄 확보 — 미보유분은 서버가 동기 적재 후 반환.
// 반환: matchId → 맵 이름(확보 실패 시 ''). 실패 시 빈 객체.
async function ensureMatchMaps(matchIds: string[]): Promise<Record<string, string>> {
if (!matchIds.length) return {}
try {
return (await api.post('/sa-matches/details/maps', {
matchIds,
})) as unknown as Record<string, string>
} catch {
return {}
}
}
// [개발 전용] 매치 동기화 크론 수동 트리거. 성공 시 true (서버는 백그라운드 실행).
async function devSync(): Promise<boolean> {
try {
const res = (await api.post('/sa-matches/dev/sync')) as unknown as {
triggered: boolean
}
return !!res?.triggered
} catch {
return false
}
}
// [개발 전용] 매치 데이터 적재 현황 조회. 실패 시 null.
async function devStats(): Promise<DevStats | null> {
try {
return (await api.get('/sa-matches/dev/stats')) as unknown as DevStats
} catch {
return null
}
}
return { getMatches, getMatchDetail, ensureMatchMaps, devSync, devStats }
}
+19 -5
View File
@@ -9,15 +9,29 @@ export interface SaMetaItem {
// 종류별 메타 { grade, season_grade, tier }
export type SaMetaSections = Record<string, SaMetaItem[]>
// 메타는 거의 불변 — 모듈 스코프에 1회 캐시해 컴포넌트 간 공유한다(중복 요청 방지).
let metaCache: SaMetaSections | null = null
let metaPromise: Promise<SaMetaSections> | null = null
export function useSaMeta() {
const api = useApi()
async function getMeta(): Promise<SaMetaSections> {
try {
return (await api.get('/sa-meta')) as unknown as SaMetaSections
} catch {
return {}
}
if (metaCache) return metaCache
// 동시 호출은 진행 중인 Promise 를 공유
if (metaPromise) return metaPromise
metaPromise = (async () => {
try {
const data = (await api.get('/sa-meta')) as unknown as SaMetaSections
metaCache = data
return data
} catch {
// 실패 시 캐시하지 않고 다음 호출에서 재시도
metaPromise = null
return {}
}
})()
return metaPromise
}
// 종류 + 이름으로 이미지 URL 조회 (없으면 빈 문자열)
+71
View File
@@ -0,0 +1,71 @@
import { useApi } from './useApi'
// 랭크전 시즌 (백엔드 SeasonResponseDto)
export interface SaSeason {
id: number
name: string
// 시작/종료일 (YYYY-MM-DD)
startDate: string
endDate: string
// 5v5 / 3v3 맵 목록 (관리자 설정)
maps5v5: string[]
maps3v3: string[]
}
// 시즌 등록/수정 입력 (id 제외)
export type SeasonInput = Omit<SaSeason, 'id'>
export function useSaSeason() {
const api = useApi()
// 등록된 시즌 목록 (시작일 내림차순). 실패 시 빈 배열.
async function getSeasons(): Promise<SaSeason[]> {
try {
return (await api.get('/sa-seasons')) as unknown as SaSeason[]
} catch {
return []
}
}
// 현재 활성 시즌 (오늘 포함). 없거나 실패 시 null.
async function getActiveSeason(): Promise<SaSeason | null> {
try {
return (await api.get('/sa-seasons/active')) as unknown as SaSeason | null
} catch {
return null
}
}
// [관리자] 시즌 등록. 실패 시 null (인터셉터가 에러 알림 처리).
async function createSeason(input: SeasonInput): Promise<SaSeason | null> {
try {
return (await api.post('/sa-seasons', input)) as unknown as SaSeason
} catch {
return null
}
}
// [관리자] 시즌 수정. 실패 시 null.
async function updateSeason(
id: number,
input: Partial<SeasonInput>,
): Promise<SaSeason | null> {
try {
return (await api.patch(`/sa-seasons/${id}`, input)) as unknown as SaSeason
} catch {
return null
}
}
// [관리자] 시즌 삭제. 성공 시 true.
async function deleteSeason(id: number): Promise<boolean> {
try {
await api.delete(`/sa-seasons/${id}`)
return true
} catch {
return false
}
}
return { getSeasons, getActiveSeason, createSeason, updateSeason, deleteSeason }
}
+58 -4
View File
@@ -33,6 +33,26 @@ export interface SaProfile {
lastSyncedAt: string
}
// 클랜 활동기간 (백엔드 ClanPeriodDto) — 일별 스냅샷에서 파생한 한 번의 활동 구간
export interface ClanPeriod {
clan: string
from: string
to: string
// 진행 중 여부 (현재 소속 클랜이면 true — 종료일 대신 '진행중' 표시)
ongoing: boolean
// 가입일 추정 여부 (최초 관측 기간만 true — 실제 가입은 더 이전일 수 있음)
estimated: boolean
}
// 시즌별 티어/점수 (백엔드 SeasonTierDto) — 시즌 종료 시점 스냅샷 기준
export interface SeasonTier {
seasonId: number
soloTier: string
soloScore: number
partyTier: string
partyScore: number
}
export function useSaUser() {
const api = useApi()
const loading = ref<boolean>(false)
@@ -54,15 +74,49 @@ export function useSaUser() {
}
}
// OUID → 종합 프로필
async function getProfile(ouid: string): Promise<SaProfile | null> {
// OUID → 종합 프로필. refresh=true 면 넥슨에서 강제 재동기화(전적 갱신).
async function getProfile(
ouid: string,
refresh = false,
): Promise<SaProfile | null> {
try {
return (await api.get(`/sa-users/${ouid}/profile`)) as unknown as SaProfile
return (await api.get(`/sa-users/${ouid}/profile`, {
params: refresh ? { refresh: true } : undefined,
})) as unknown as SaProfile
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return null
}
}
return { loading, error, searchOuid, getProfile }
// OUID → 클랜 활동기간 타임라인 (실패 시 빈 배열)
async function getClanPeriods(ouid: string): Promise<ClanPeriod[]> {
try {
return (await api.get(
`/sa-users/${ouid}/clan-periods`,
)) as unknown as ClanPeriod[]
} catch {
return []
}
}
// OUID → 시즌별 티어/점수 (시즌 종료 시점 스냅샷). 실패 시 빈 배열.
async function getSeasonTiers(ouid: string): Promise<SeasonTier[]> {
try {
return (await api.get(
`/sa-users/${ouid}/season-tiers`,
)) as unknown as SeasonTier[]
} catch {
return []
}
}
return {
loading,
error,
searchOuid,
getProfile,
getClanPeriods,
getSeasonTiers,
}
}
+336 -68
View File
@@ -1,30 +1,52 @@
<script setup lang="ts">
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
// UI 구조는 기존(mock 디자인) 그대로 유지하고, 데이터만 넥슨 실데이터를 어댑터로 변환해 주입한다.
import { ref, watch } from 'vue'
import { defineAsyncComponent, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSaUser, type SaProfile } from '@/composables/useSaUser'
import {
useSaUser,
type SaProfile,
type ClanPeriod,
type SeasonTier,
} from '@/composables/useSaUser'
import { useSaMatch, MATCH_TYPES, type SaMatchItem } from '@/composables/useSaMatch'
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
import { makeRank } from '@/shared/utils/adapt'
import type { Player, MatchRecord, RankedRecord, ClanRecord } from '@/shared/mock/types'
import { timeAgo, toKstDate } from '@/shared/utils/format'
import type {
Player,
MatchRecord,
RankedRecord,
ClanRecord,
ModeRecord,
MapRecord,
TrendPoint,
} from '@/shared/mock/types'
import SearchBar from '@/shared/components/SearchBar.vue'
import ProfileSummary from '@/pages/profile/ProfileSummary.vue'
import RankedSeasons from '@/pages/profile/RankedSeasons.vue'
import ClanRecords from '@/pages/profile/ClanRecords.vue'
import MatchHistory from '@/pages/profile/MatchHistory.vue'
import RecentTrend from '@/pages/profile/RecentTrend.vue'
import Companions from '@/pages/profile/Companions.vue'
import TrendChart from '@/pages/profile/TrendChart.vue'
// '자주 함께한 유저'는 정확성 보장 불가로 비활성화 — 매치 상세 참가자에 ouid가 없어
// 동반자(특히 개명 시)를 신뢰성 있게 식별·병합할 수 없음. Open API에 참가자 ouid 제공 시 복구.
// import Companions from '@/pages/profile/Companions.vue'
// 차트 라이브러리(ApexCharts)가 무거우므로 통계 탭에서 렌더될 때만 비동기 로드
const TrendChart = defineAsyncComponent(
() => import('@/pages/profile/TrendChart.vue'),
)
import ModeRecords from '@/pages/profile/ModeRecords.vue'
import MapRecords from '@/pages/profile/MapRecords.vue'
const route = useRoute()
const { searchOuid, getProfile } = useSaUser()
const { searchOuid, getProfile, getClanPeriods, getSeasonTiers } = useSaUser()
const { getMatches } = useSaMatch()
const { getMeta, imageOf } = useSaMeta()
const { getSeasons } = useSaSeason()
const loading = ref(false)
const refreshing = ref(false)
const notFound = ref(false)
const p = ref<Player | null>(null)
const tab = ref('전적')
@@ -38,16 +60,6 @@ const TYPE_LABEL: Record<string, string> = {
토너먼트: '토너먼트',
}
// ISO → 'MM.DD HH:mm' (KST)
function fmtAgo(iso: string): string {
const kst = new Date(new Date(iso).getTime() + 9 * 3600 * 1000)
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0')
const dd = String(kst.getUTCDate()).padStart(2, '0')
const hh = String(kst.getUTCHours()).padStart(2, '0')
const mi = String(kst.getUTCMinutes()).padStart(2, '0')
return `${mm}.${dd} ${hh}:${mi}`
}
// 킬뎃 %(recentKdRate)를 kda 값으로 역산 (ProfileSummary 가 SA.kdPct 로 다시 %로 환산하므로)
function kdaFromPct(pct: number): number {
const r = Math.min(99, Math.max(0, pct))
@@ -86,36 +98,155 @@ function aggRanked(
}
}
// 클랜전 전적 — 클랜이 있으면 클랜전(퀵매치 클랜전 + 클랜 랭크전) 매치를 집계.
// 전적이 없으면 0으로 표기, 클랜이 없으면 빈 목록.
function aggClanRecords(items: SaMatchItem[], clanName: string): ClanRecord[] {
if (!clanName) return []
const clanTypes = ['퀵매치 클랜전', '클랜 랭크전']
const f = items.filter((m) => clanTypes.includes(m.matchType))
const matches = f.length
const w = f.filter((x) => x.matchResult === '1').length
const l = matches - w
let kdSum = 0
f.forEach((x) => {
const t = x.kill + x.death
kdSum += t ? (x.kill / t) * 100 : 0
// 시즌별 랭크전 전적 구성.
// - 시즌이 등록돼 있으면: 각 시즌 [startDate, endDate]에 드는 매치만 버킷팅해 집계.
// 티어/티어점수는 현재 시즌=라이브 프로필, 과거 시즌=시즌 종료 스냅샷(seasonTiers). 둘 다 없으면 '—'.
// - 시즌이 없으면: 보유 매치 전체를 단일 '현재 시즌'으로 (기존 동작 폴백).
function buildRankedSeasons(
pr: SaProfile,
matches: SaMatchItem[],
meta: SaMetaSections,
seasons: SaSeason[],
seasonTiers: SeasonTier[],
): RankedSeason[] {
// 솔로/파티 티어·점수를 받아 한 구간의 랭크전 집계 생성
const mk = (
items: SaMatchItem[],
soloTier: string,
soloScore: number,
partyTier: string,
partyScore: number,
) => ({
solo: aggRanked(
items,
'랭크전 솔로',
soloTier,
soloScore,
soloTier ? imageOf(meta, 'tier', soloTier) : '',
),
party: aggRanked(
items,
'랭크전 파티',
partyTier,
partyScore,
partyTier ? imageOf(meta, 'tier', partyTier) : '',
),
// 클랜 랭크전 — 티어/티어점수 API 미제공이라 항상 빈값
clan: aggRanked(items, '클랜 랭크전', '', 0, ''),
})
// 현재 가입된 클랜이므로 가입 1회로 카운트. 가입일은 알 수 없어 '????',
// 종료는 현재(활동중) 년월(yyyy-mm)로 표기한다.
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000)
const nowYm = `${kst.getUTCFullYear()}-${String(kst.getUTCMonth() + 1).padStart(2, '0')}`
return [
{
clan: clanName,
tag: clanName.slice(0, 4),
if (!seasons.length) {
return [
{
season: '현재 시즌',
current: true,
hasTier: true,
...mk(matches, pr.soloTier, pr.soloScore, pr.partyTier, pr.partyScore),
},
]
}
const today = toKstDate(new Date())
const tierBySeason = new Map(seasonTiers.map((t) => [t.seasonId, t]))
return seasons.map((s) => {
// 매치 일시(UTC 저장)를 KST 일자로 변환해 시즌 경계와 같은 기준으로 비교
const inSeason = matches.filter((m) => {
const d = toKstDate(m.dateMatch)
return d >= s.startDate && d <= s.endDate
})
const isCurrent = today >= s.startDate && today <= s.endDate
const t = tierBySeason.get(s.id)
// 현재 시즌은 라이브 프로필 티어, 과거 시즌은 시즌 종료 스냅샷 티어, 없으면 빈값('—')
const soloTier = isCurrent ? pr.soloTier : (t?.soloTier ?? '')
const soloScore = isCurrent ? pr.soloScore : (t?.soloScore ?? 0)
const partyTier = isCurrent ? pr.partyTier : (t?.partyTier ?? '')
const partyScore = isCurrent ? pr.partyScore : (t?.partyScore ?? 0)
return {
season: s.name,
current: isCurrent,
hasTier: isCurrent || !!t,
...mk(inSeason, soloTier, soloScore, partyTier, partyScore),
}
})
}
// 랭크전 통산 — 기간(시즌) 무관 전체 랭크전 매치 합산. 티어/점수는 통산에 없으므로 빈값.
function buildRankedCareer(matches: SaMatchItem[]): RankedSeason {
return {
season: '통산',
current: false,
hasTier: false,
solo: aggRanked(matches, '랭크전 솔로', '', 0, ''),
party: aggRanked(matches, '랭크전 파티', '', 0, ''),
clan: aggRanked(matches, '클랜 랭크전', '', 0, ''),
}
}
// 두 'YYYY-MM-DD' 사이의 개월 수(근사)
function monthsBetween(from: string, to: string): number {
const [fy, fm] = from.split('-').map(Number)
const [ty, tm] = to.split('-').map(Number)
if (!fy || !ty) return 0
return Math.max(0, (ty - fy) * 12 + (tm - fm))
}
// 클랜전 전적 — 스냅샷에서 파생한 클랜 활동기간(periods) 기준으로 집계.
// 활동기간 [from, to] 안의 '퀵매치 클랜전'(클랜 랭크전 제외) 매치만 엄격하게 날짜 필터링한다.
// 가입일은 추정(estimated)이라 조회일 이전 매치는 자연히 제외된다.
function buildClanRecords(items: SaMatchItem[], periods: ClanPeriod[]): ClanRecord[] {
if (!periods.length) return []
// 클랜별로 활동기간(run) 묶기
const byClan = new Map<string, ClanPeriod[]>()
for (const p of periods) {
const arr = byClan.get(p.clan) ?? []
arr.push(p)
byClan.set(p.clan, arr)
}
// 퀵매치 클랜전만 (클랜 랭크전 제외)
const clanMatches = items.filter((m) => m.matchType === '퀵매치 클랜전')
// ISO 일시가 활동기간 [from, to](KST 일자 기준, 양끝 포함)에 드는지
const inRun = (iso: string, r: ClanPeriod) => {
const d = toKstDate(iso)
return d >= r.from && d <= r.to
}
const records: ClanRecord[] = []
for (const [clan, runs] of byClan) {
const f = clanMatches.filter((m) => runs.some((r) => inRun(m.dateMatch, r)))
const matches = f.length
const w = f.filter((x) => x.matchResult === '1').length
const l = matches - w
let kdSum = 0
f.forEach((x) => {
const t = x.kill + x.death
kdSum += t ? (x.kill / t) * 100 : 0
})
records.push({
clan,
tag: clan.slice(0, 4),
matches,
w,
l,
wr: matches ? Math.round((w / matches) * 100) : 0,
kd: matches ? Math.round(kdSum / matches) : 0,
periods: [{ from: '????', to: nowYm, months: 0 }],
},
]
periods: runs
.slice()
.sort((a, b) => b.from.localeCompare(a.from))
.map((r) => ({
from: r.from,
to: r.to,
months: monthsBetween(r.from, r.to),
ongoing: r.ongoing,
estimated: r.estimated,
})),
})
}
// 최근 활동(가장 늦은 from) 순으로 정렬
records.sort((a, b) => b.periods[0].from.localeCompare(a.periods[0].from))
return records
}
function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
@@ -127,23 +258,132 @@ function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
k: m.kill,
d: m.death,
a: m.assist,
ago: fmtAgo(m.dateMatch),
ago: timeAgo(m.dateMatch),
winSide: 'blue',
teams: { blue: [], red: [] },
}))
}
// 모드별 전적 — 매치 타입별 집계. (게임 모드는 폭파미션만 수집하므로 매치 타입 기준)
// 랭크전 3종(솔로/파티/클랜 랭크전)은 0전이어도 항상 표시하고, 고정 순서로 정렬한다.
const MODE_ORDER = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전', '클랜전', '토너먼트']
const MODE_ALWAYS = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전']
function buildModeRecords(matches: SaMatchItem[]): ModeRecord[] {
const byType = new Map<string, { matches: number; w: number; kdSum: number }>()
// 랭크전 3종은 0전이어도 표기되도록 미리 시드
for (const name of MODE_ALWAYS) byType.set(name, { matches: 0, w: 0, kdSum: 0 })
for (const m of matches) {
const label = TYPE_LABEL[m.matchType] ?? m.matchType
const cur = byType.get(label) ?? { matches: 0, w: 0, kdSum: 0 }
cur.matches++
if (m.matchResult === '1') cur.w++
const t = m.kill + m.death
cur.kdSum += t ? (m.kill / t) * 100 : 0
byType.set(label, cur)
}
const orderIdx = (name: string) => {
const i = MODE_ORDER.indexOf(name)
return i === -1 ? MODE_ORDER.length : i
}
return [...byType.entries()]
.map(([name, s]) => ({
name,
matches: s.matches,
w: s.w,
l: s.matches - s.w,
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
}))
.sort((a, b) => orderIdx(a.name) - orderIdx(b.name))
}
// 맵별 전적 — 상세 적재된 매치의 matchMap 으로 집계(맵 미보유=상세 미적재 매치는 제외).
// 매치 많은 순. 커버리지는 상세 적재분에 한정(크론이 쌓일수록 완전해짐).
function buildMapRecords(matches: SaMatchItem[]): MapRecord[] {
const byMap = new Map<string, { matches: number; w: number; kdSum: number }>()
for (const m of matches) {
const map = m.matchMap
if (!map) continue
const cur = byMap.get(map) ?? { matches: 0, w: 0, kdSum: 0 }
cur.matches++
if (m.matchResult === '1') cur.w++
const t = m.kill + m.death
cur.kdSum += t ? (m.kill / t) * 100 : 0
byMap.set(map, cur)
}
return [...byMap.entries()]
.map(([name, s]) => ({
name,
matches: s.matches,
w: s.w,
l: s.matches - s.w,
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
}))
.sort((a, b) => b.matches - a.matches)
}
// 최근 동향 — 최근 매치를 20경기씩 묶어 버킷별 승률·킬뎃 추이(최대 200경기=10버킷).
// 20경기 미만의 자투리 버킷은 제외(승률/킬뎃 신뢰도). 결과는 오래된→최신 순(차트 좌→우).
function buildTrend(matches: SaMatchItem[]): TrendPoint[] {
const BUCKET = 20
const MAX = 200
const recent = matches.slice(0, MAX) // 최신순(DESC)
const fullCount = Math.floor(recent.length / BUCKET) * BUCKET
const points: TrendPoint[] = []
for (let i = 0; i < fullCount; i += BUCKET) {
const chunk = recent.slice(i, i + BUCKET) // 최신쪽 20경기
const w = chunk.filter((m) => m.matchResult === '1').length
let kdSum = 0
chunk.forEach((m) => {
const t = m.kill + m.death
kdSum += t ? (m.kill / t) * 100 : 0
})
points.push({
wr: Math.round((w / BUCKET) * 100),
kd: Math.round(kdSum / BUCKET),
label: `${i + 1}-${i + BUCKET}`, // 최신 기준 경기 범위 (예: 1-20 = 최근 20경기)
})
}
// points 는 최신→과거 순(i=0 이 최신) → 차트는 좌=과거이므로 뒤집는다
return points.reverse()
}
// 접속률 — 최근 90일 중 매치 기록이 있는 날(=접속·플레이한 날)의 비율(%).
// 우리가 가진 가장 정확한 활동 신호가 '그 날 매치 존재' 여부다(스냅샷은 우리 크론 기록이라 활동 신호가 아님).
function calcConnRate(matches: SaMatchItem[]): number {
const WINDOW = 90
const today = toKstDate(new Date())
const from = toKstDate(new Date(Date.now() - (WINDOW - 1) * 24 * 60 * 60 * 1000))
const days = new Set<string>()
for (const m of matches) {
const d = toKstDate(m.dateMatch)
if (d >= from && d <= today) days.add(d)
}
return Math.round((days.size / WINDOW) * 100)
}
// 넥슨 프로필/매치 → 기존 UI가 기대하는 mock Player 형태로 변환.
// 넥슨이 제공하지 않는 항목(집계/추세/함께한 유저 등)은 빈 값으로 두고 2차에서 채운다.
function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections): Player {
function adaptPlayer(
pr: SaProfile,
matches: SaMatchItem[],
meta: SaMetaSections,
clanPeriods: ClanPeriod[],
seasons: SaSeason[],
seasonTiers: SeasonTier[],
): Player {
const gradeImg = imageOf(meta, 'grade', pr.grade)
const seasonImg = imageOf(meta, 'season_grade', pr.seasonGrade)
const totalRank = makeRank(pr.grade, gradeImg)
const seasonRank = makeRank(pr.seasonGrade, seasonImg)
return {
name: pr.userName,
clan: pr.clanName || null,
rank: makeRank(pr.grade, gradeImg),
rankTotal: makeRank(pr.grade, gradeImg),
rankSeason: makeRank(pr.seasonGrade, seasonImg),
// 대표 계급 — 시즌 계급 우선(시즌 미배치 시 통합 계급으로 폴백)
rank: pr.seasonGrade ? seasonRank : totalRank,
rankTotal: totalRank,
rankSeason: seasonRank,
rankNoTotal: pr.gradeRanking,
rankNoSeason: pr.seasonGradeRanking,
level: 0,
@@ -157,61 +397,83 @@ function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections
special: Math.round(pr.recentSpecialRate),
manner: 0,
mannerGrade: pr.mannerGrade,
connRate: 0,
connRate: calcConnRate(matches),
views: 0,
contrib: 0,
matches: adaptMatches(matches),
rankedSeasons: [
{
season: '현재 시즌',
solo: aggRanked(matches, '랭크전 솔로', pr.soloTier, pr.soloScore, imageOf(meta, 'tier', pr.soloTier)),
party: aggRanked(matches, '랭크전 파티', pr.partyTier, pr.partyScore, imageOf(meta, 'tier', pr.partyTier)),
},
],
clanRecords: aggClanRecords(matches, pr.clanName),
modeRecords: [],
mapRecords: [],
trend: { daily: [], weekly: [] },
rankedSeasons: buildRankedSeasons(pr, matches, meta, seasons, seasonTiers),
rankedCareer: buildRankedCareer(matches),
clanRecords: buildClanRecords(matches, clanPeriods),
modeRecords: buildModeRecords(matches),
mapRecords: buildMapRecords(matches),
trend: buildTrend(matches),
together: { games: 0, w: 0, l: 0 },
}
}
async function load() {
// opts.soft: 페이지를 비우지 않고 갱신(전적 갱신) / opts.force: 넥슨에서 프로필 강제 재동기화
async function load(opts: { force?: boolean; soft?: boolean } = {}) {
const name = (route.params.name as string)?.trim()
if (!name) return
loading.value = true
notFound.value = false
p.value = null
if (opts.soft) {
refreshing.value = true
} else {
loading.value = true
notFound.value = false
p.value = null
}
const res = await searchOuid(name)
if (!res) {
notFound.value = true
if (!opts.soft) notFound.value = true
loading.value = false
refreshing.value = false
return
}
const results = await Promise.all([
getProfile(res.ouid),
getProfile(res.ouid, opts.force),
getMeta(),
getClanPeriods(res.ouid),
getSeasons(),
getSeasonTiers(res.ouid),
...MATCH_TYPES.map((t) => getMatches(res.ouid, t)),
])
const profile = results[0] as SaProfile | null
const meta = results[1] as SaMetaSections
const matchLists = results.slice(2) as SaMatchItem[][]
const clanPeriods = results[2] as ClanPeriod[]
const seasons = results[3] as SaSeason[]
const seasonTiers = results[4] as SeasonTier[]
const matchLists = results.slice(5) as SaMatchItem[][]
if (!profile) {
notFound.value = true
if (!opts.soft) notFound.value = true
loading.value = false
refreshing.value = false
return
}
const allMatches = matchLists
.flat()
.sort((a, b) => b.dateMatch.localeCompare(a.dateMatch))
p.value = adaptPlayer(profile, allMatches, meta)
p.value = adaptPlayer(
profile,
allMatches,
meta,
clanPeriods,
seasons,
seasonTiers,
)
loading.value = false
refreshing.value = false
}
watch(() => route.params.name, load, { immediate: true })
// 전적 갱신 — 페이지를 유지한 채(soft) 넥슨에서 강제 재동기화(force)
function onRefresh() {
if (refreshing.value) return
void load({ force: true, soft: true })
}
watch(() => route.params.name, () => load(), { immediate: true })
</script>
<template>
@@ -240,7 +502,11 @@ watch(() => route.params.name, load, { immediate: true })
</div>
<template v-else-if="p">
<ProfileSummary :p="p" />
<ProfileSummary
:p="p"
:refreshing="refreshing"
@refresh="onRefresh"
/>
<!-- 서브탭 -->
<div class="pf__tabs">
@@ -271,7 +537,9 @@ watch(() => route.params.name, load, { immediate: true })
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<!-- '자주 함께한 유저' 비활성화 정확성 보장 불가(참가자 ouid 부재). 추후 대체 콘텐츠 예정
<Companions :p="p" />
-->
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
// 관리자 대시보드 셸 — 좌측 사이드바(섹션 네비) + 우측 콘텐츠 슬롯. 풀 너비.
import { RouterLink } from 'vue-router'
const NAV = [
{ to: '/admin/maps', label: '맵 관리' },
{ to: '/admin/seasons', label: '시즌 관리' },
]
</script>
<template>
<div class="adm">
<aside class="adm__side">
<div class="adm__brand">
관리자
</div>
<nav class="adm__nav">
<RouterLink
v-for="n in NAV"
:key="n.to"
:to="n.to"
class="adm__navitem"
>
{{ n.label }}
</RouterLink>
</nav>
</aside>
<main class="adm__main">
<slot />
</main>
</div>
</template>
<style scoped>
.adm {
display: flex;
gap: 1.25rem;
width: 100%;
padding: 1.5rem 1.75rem 2.75rem;
}
.adm__side {
flex-shrink: 0;
width: 12rem;
}
.adm__brand {
font-size: 0.75rem;
font-weight: 800;
color: var(--t3);
letter-spacing: 0.04em;
padding: 0 0.75rem 0.625rem;
}
.adm__nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.adm__navitem {
display: block;
padding: 0.5rem 0.75rem;
border-radius: var(--r-sm);
font-size: 0.8125rem;
font-weight: 700;
color: var(--t2);
text-decoration: none;
}
.adm__navitem:hover {
background: #f1f2f4;
}
.adm__navitem.router-link-active {
background: var(--ink);
color: #fff;
}
.adm__main {
flex: 1;
min-width: 0;
}
@media (max-width: 960px) {
.adm {
flex-direction: column;
padding: 1.25rem 1rem 2.5rem;
}
.adm__side {
width: 100%;
}
.adm__nav {
flex-direction: row;
}
}
</style>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
// 관리자 — 서든어택 맵 이름 등록/수정/삭제. 5v5/3v3 구분은 시즌 설정에서 토글로 정한다.
import { computed, onMounted, ref } from 'vue'
import { useSaMap, type SaMap } from '@/composables/useSaMap'
import AdminLayout from './AdminLayout.vue'
import UiPanel from '@/shared/components/UiPanel.vue'
const { getMaps, createMap, updateMap, deleteMap } = useSaMap()
const maps = ref<SaMap[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const editingId = ref<number | null>(null)
const name = ref('')
const isEditing = computed(() => editingId.value !== null)
async function loadMaps() {
loading.value = true
maps.value = await getMaps()
loading.value = false
}
function resetForm() {
editingId.value = null
name.value = ''
error.value = ''
}
function startEdit(m: SaMap) {
editingId.value = m.id
name.value = m.name
error.value = ''
}
async function submit() {
if (saving.value) return
if (!name.value.trim()) {
error.value = '맵 이름을 입력해 주세요.'
return
}
saving.value = true
const input = { name: name.value.trim() }
const ok = isEditing.value
? await updateMap(editingId.value as number, input)
: await createMap(input)
saving.value = false
if (ok) {
resetForm()
await loadMaps()
}
}
async function remove(m: SaMap) {
if (!window.confirm(`'${m.name}' 맵을 삭제할까요?`)) return
const ok = await deleteMap(m.id)
if (ok) {
if (editingId.value === m.id) resetForm()
await loadMaps()
}
}
onMounted(loadMaps)
</script>
<template>
<AdminLayout>
<header class="adm__head">
<h1 class="adm__title">
관리
</h1>
<p class="adm__desc">
서든어택 이름을 등록합니다. 5v5 / 3v3 구분은 시즌 설정에서 목록을 토글로 선택합니다.
</p>
</header>
<div class="adm__grid">
<!-- 등록/수정 -->
<UiPanel
:title="isEditing ? '맵 수정' : '맵 등록'"
en="MAP"
body-pad="18px 20px"
>
<form
class="adm__form"
@submit.prevent="submit"
>
<div class="adm__field">
<label
class="adm__label"
for="map-name"
> 이름</label>
<input
id="map-name"
v-model="name"
class="adm__input"
type="text"
placeholder="예: 제3보급창고"
maxlength="50"
>
</div>
<p
v-if="error"
class="adm__error"
role="alert"
>
{{ error }}
</p>
<div class="adm__actions">
<button
v-if="isEditing"
type="button"
class="adm__btn"
@click="resetForm"
>
취소
</button>
<button
type="submit"
class="adm__btn adm__btn--primary"
:disabled="saving"
>
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
</button>
</div>
</form>
</UiPanel>
<!-- 목록 -->
<UiPanel
title="등록된 맵"
en="LIST"
body-pad="0"
>
<template #right>
<span class="adm__count">{{ maps.length }}</span>
</template>
<div
v-if="loading"
class="adm__empty"
>
불러오는
</div>
<div
v-else-if="!maps.length"
class="adm__empty"
>
등록된 맵이 없습니다.
</div>
<div
v-else
class="rectable-scroll"
>
<table class="rectable map__table">
<thead>
<tr>
<th class="map__th-no">
#
</th>
<th></th>
<th class="ta-r">
관리
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(m, i) in maps"
:key="m.id"
:class="{
'map__row--alt': i % 2 === 1,
'map__row--editing': editingId === m.id,
}"
>
<td class="td-num map__no">
{{ i + 1 }}
</td>
<td class="td-strong">
{{ m.name }}
</td>
<td class="ta-r map__actions">
<button
type="button"
class="map__link"
@click="startEdit(m)"
>
수정
</button>
<button
type="button"
class="map__link map__link--danger"
@click="remove(m)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</UiPanel>
</div>
</AdminLayout>
</template>
<style scoped>
.adm__head {
margin-bottom: 1.25rem;
}
.adm__title {
margin: 0;
font-size: 1.375rem;
font-weight: 800;
color: var(--ink);
}
.adm__desc {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--t3);
}
.adm__grid {
display: grid;
grid-template-columns: 22rem 1fr;
gap: 1.25rem;
align-items: start;
}
.adm__count {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.adm__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.adm__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.adm__label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--t2);
}
.adm__input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
font-family: var(--font-body);
font-size: 0.8125rem;
color: var(--ink);
background: var(--surf);
}
.adm__input:focus-visible {
outline: 2px solid var(--point);
outline-offset: 1px;
}
.adm__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.adm__btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.adm__btn--primary {
border: none;
background: var(--ink);
color: #fff;
}
.adm__btn:disabled {
opacity: 0.6;
cursor: default;
}
.adm__error {
margin: 0;
font-size: 0.78125rem;
font-weight: 600;
color: var(--negative);
}
.adm__empty {
padding: 1.75rem 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
/* 맵 목록 표 — 전적검색 모드별 통계(.rectable)와 동일 디자인 */
.map__table tbody tr {
border-bottom: 1px solid var(--line3);
}
.map__table tbody tr:last-child {
border-bottom: none;
}
.map__row--alt {
background: #fbfbfc;
}
.map__row--editing {
background: color-mix(in srgb, var(--point) 8%, var(--surf));
}
.map__th-no,
.map__no {
width: 2.5rem;
color: var(--t3);
}
.map__actions {
white-space: nowrap;
}
.map__link {
border: none;
background: none;
cursor: pointer;
font-family: var(--font-body);
font-size: 0.78125rem;
font-weight: 700;
color: var(--t2);
padding: 0.25rem 0.375rem;
}
.map__link:hover {
text-decoration: underline;
}
.map__link--danger {
color: var(--negative);
}
@media (max-width: 960px) {
.adm__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,448 @@
<script setup lang="ts">
// 관리자 — 시즌 관리(등록/수정/삭제 + 5v5/3v3 맵 토글 선택). 맵은 '맵 관리'에 등록된 목록에서 토글.
import { computed, onMounted, ref } from 'vue'
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
import { useSaMap, type SaMap } from '@/composables/useSaMap'
import AdminLayout from './AdminLayout.vue'
import MapToggleGroup from './MapToggleGroup.vue'
import UiPanel from '@/shared/components/UiPanel.vue'
const { getSeasons, createSeason, updateSeason, deleteSeason } = useSaSeason()
const { getMaps } = useSaMap()
const seasons = ref<SaSeason[]>([])
const allMaps = ref<SaMap[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref('')
// 폼 상태 — editingId 가 null 이면 등록, 값이 있으면 수정 모드
const editingId = ref<number | null>(null)
const name = ref('')
const startDate = ref('')
const endDate = ref('')
const maps5v5 = ref<string[]>([])
const maps3v3 = ref<string[]>([])
const isEditing = computed(() => editingId.value !== null)
async function loadSeasons() {
loading.value = true
seasons.value = await getSeasons()
loading.value = false
}
function resetForm() {
editingId.value = null
name.value = ''
startDate.value = ''
endDate.value = ''
maps5v5.value = []
maps3v3.value = []
error.value = ''
}
function startEdit(s: SaSeason) {
editingId.value = s.id
name.value = s.name
startDate.value = s.startDate
endDate.value = s.endDate
maps5v5.value = [...s.maps5v5]
maps3v3.value = [...s.maps3v3]
error.value = ''
}
function validate(): boolean {
if (!name.value.trim()) {
error.value = '시즌명을 입력해 주세요.'
return false
}
if (!startDate.value || !endDate.value) {
error.value = '시작일과 종료일을 모두 선택해 주세요.'
return false
}
if (endDate.value < startDate.value) {
error.value = '종료일은 시작일보다 빠를 수 없습니다.'
return false
}
return true
}
async function submit() {
if (saving.value || !validate()) return
saving.value = true
const input = {
name: name.value.trim(),
startDate: startDate.value,
endDate: endDate.value,
maps5v5: maps5v5.value,
maps3v3: maps3v3.value,
}
const ok = isEditing.value
? await updateSeason(editingId.value as number, input)
: await createSeason(input)
saving.value = false
if (ok) {
resetForm()
await loadSeasons()
}
}
async function remove(s: SaSeason) {
if (!window.confirm(`'${s.name}' 시즌을 삭제할까요?`)) return
const ok = await deleteSeason(s.id)
if (ok) {
if (editingId.value === s.id) resetForm()
await loadSeasons()
}
}
onMounted(async () => {
allMaps.value = await getMaps()
await loadSeasons()
})
</script>
<template>
<AdminLayout>
<header class="adm__head">
<h1 class="adm__title">
시즌 관리
</h1>
<p class="adm__desc">
랭크전 시즌 기간과 5v5 / 3v3 맵을 등록하면, 시즌별 전적 집계의 기준이 됩니다.
</p>
</header>
<div class="adm__grid">
<!-- 등록/수정 -->
<UiPanel
:title="isEditing ? '시즌 수정' : '시즌 등록'"
en="SEASON"
body-pad="18px 20px"
>
<form
class="adm__form"
@submit.prevent="submit"
>
<div class="adm__field">
<label
class="adm__label"
for="season-name"
>시즌명</label>
<input
id="season-name"
v-model="name"
class="adm__input"
type="text"
placeholder="예: 2026 시즌 1"
maxlength="50"
>
</div>
<div class="adm__dates">
<div class="adm__field">
<label
class="adm__label"
for="season-start"
>시작일</label>
<input
id="season-start"
v-model="startDate"
class="adm__input"
type="date"
>
</div>
<div class="adm__field">
<label
class="adm__label"
for="season-end"
>종료일</label>
<input
id="season-end"
v-model="endDate"
class="adm__input"
type="date"
>
</div>
</div>
<MapToggleGroup
v-model="maps5v5"
label="5v5 맵"
:available="allMaps"
/>
<MapToggleGroup
v-model="maps3v3"
label="3v3 맵"
:available="allMaps"
/>
<p
v-if="error"
class="adm__error"
role="alert"
>
{{ error }}
</p>
<div class="adm__actions">
<button
v-if="isEditing"
type="button"
class="adm__btn"
@click="resetForm"
>
취소
</button>
<button
type="submit"
class="adm__btn adm__btn--primary"
:disabled="saving"
>
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
</button>
</div>
</form>
</UiPanel>
<!-- 시즌 목록 -->
<UiPanel
title="등록된 시즌"
en="LIST"
body-pad="14px 16px"
>
<template #right>
<span class="adm__count">{{ seasons.length }}</span>
</template>
<div
v-if="loading"
class="adm__empty"
>
불러오는
</div>
<div
v-else-if="!seasons.length"
class="adm__empty"
>
등록된 시즌이 없습니다.
</div>
<table
v-else
class="adm__table"
>
<thead>
<tr>
<th>시즌명</th>
<th>기간</th>
<th class="ta-c">
(5v5 / 3v3)
</th>
<th class="adm__th-actions">
관리
</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in seasons"
:key="s.id"
:class="{ 'adm__row--editing': editingId === s.id }"
>
<td class="adm__td-name">
{{ s.name }}
</td>
<td class="num adm__td-period">
{{ s.startDate }} ~ {{ s.endDate }}
</td>
<td class="ta-c num">
{{ s.maps5v5.length }} / {{ s.maps3v3.length }}
</td>
<td class="adm__td-actions">
<button
type="button"
class="adm__link"
@click="startEdit(s)"
>
수정
</button>
<button
type="button"
class="adm__link adm__link--danger"
@click="remove(s)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</UiPanel>
</div>
</AdminLayout>
</template>
<style scoped>
.adm__head {
margin-bottom: 1.25rem;
}
.adm__title {
margin: 0;
font-size: 1.375rem;
font-weight: 800;
color: var(--ink);
}
.adm__desc {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--t3);
}
.adm__grid {
display: grid;
grid-template-columns: 26rem 1fr;
gap: 1.25rem;
align-items: start;
}
.adm__count {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.adm__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.adm__dates {
display: flex;
gap: 0.875rem;
}
.adm__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.adm__label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--t2);
}
.adm__input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
font-family: var(--font-body);
font-size: 0.8125rem;
color: var(--ink);
background: var(--surf);
}
.adm__input:focus-visible {
outline: 2px solid var(--point);
outline-offset: 1px;
}
.adm__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.adm__btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.adm__btn--primary {
border: none;
background: var(--ink);
color: #fff;
}
.adm__btn:disabled {
opacity: 0.6;
cursor: default;
}
.adm__error {
margin: 0;
font-size: 0.78125rem;
font-weight: 600;
color: var(--negative);
}
.adm__empty {
padding: 1.75rem 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
.adm__table {
width: 100%;
border-collapse: collapse;
}
.adm__table th {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
text-align: left;
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--line);
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
/* 헤더는 `.adm__table th`(text-align:left)가 특이도상 우선하므로 동일 이상으로 재지정 */
.adm__table th.ta-c {
text-align: center;
}
.adm__table th.adm__th-actions {
text-align: right;
}
.adm__table td {
font-size: 0.8125rem;
color: var(--ink);
padding: 0.625rem 0.5rem;
border-bottom: 1px solid var(--line3);
}
.adm__row--editing {
background: #f7f8f9;
}
.adm__td-name {
font-weight: 600;
}
.adm__td-period {
color: var(--t2);
white-space: nowrap;
}
.adm__td-actions {
text-align: right;
white-space: nowrap;
}
.adm__link {
border: none;
background: none;
cursor: pointer;
font-family: var(--font-body);
font-size: 0.78125rem;
font-weight: 700;
color: var(--t2);
padding: 0.25rem 0.375rem;
}
.adm__link:hover {
text-decoration: underline;
}
.adm__link--danger {
color: var(--negative);
}
@media (max-width: 960px) {
.adm__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
// 맵 토글 그룹 — 등록된 맵(available)을 칩으로 보여주고 선택 토글. v-model: 선택된 맵 이름 string[]
interface Props {
label: string
available: { name: string }[]
modelValue: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string[]): void
}>()
function toggle(name: string) {
const sel = props.modelValue
emit(
'update:modelValue',
sel.includes(name) ? sel.filter((n) => n !== name) : [...sel, name],
)
}
</script>
<template>
<div class="mtg">
<div class="mtg__label">
{{ label }}
<span class="mtg__count">{{ modelValue.length }} / {{ available.length }}</span>
</div>
<div
v-if="!available.length"
class="mtg__empty"
>
등록된 맵이 없습니다. 관리에서 먼저 등록하세요.
</div>
<div
v-else
class="mtg__chips"
>
<button
v-for="m in available"
:key="m.name"
type="button"
class="mtg__chip"
:class="{ 'mtg__chip--on': modelValue.includes(m.name) }"
:aria-pressed="modelValue.includes(m.name)"
@click="toggle(m.name)"
>
{{ m.name }}
</button>
</div>
</div>
</template>
<style scoped>
.mtg {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mtg__label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--t2);
}
.mtg__count {
margin-left: 0.25rem;
color: var(--point);
}
.mtg__empty {
font-size: 0.71875rem;
color: var(--t3);
}
.mtg__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* 미선택 = 기본 태그(홈 '시즌 현황' 맵 태그 .sc__map 룩) / 선택 = 브랜드 컬러 채움 */
.mtg__chip {
padding: 0.25rem 0.625rem;
border-radius: var(--r-xs);
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
font-size: 0.71875rem;
font-weight: 500;
cursor: pointer;
}
.mtg__chip:hover {
border-color: var(--point);
}
.mtg__chip--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
font-weight: 700;
}
</style>
+3 -2
View File
@@ -10,7 +10,8 @@ import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const me = SA.me
const scope = ref('통합')
// 계급 표시는 시즌 계급 우선 — 기본 탭을 '시즌'으로 둔다
const scope = ref('시즌')
const rankNo = computed(() => (scope.value === '통합' ? me.rankNoTotal : me.rankNoSeason))
const selRank = computed(() => (scope.value === '통합' ? me.rankTotal : me.rankSeason))
@@ -35,7 +36,7 @@ function open() {
<template #right>
<SegTabs
v-model="scope"
:tabs="['통합', '시즌']"
:tabs="['시즌', '통합']"
/>
</template>
+3 -2
View File
@@ -7,8 +7,9 @@ import { useRanking, type RankingItem } from '@/composables/useRanking'
import UiPanel from '@/shared/components/UiPanel.vue'
const router = useRouter()
const CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜']
const cat = ref('통합 계급')
// 계급 랭킹은 시즌 계급 우선 — 기본 카테고리를 '시즌 계급'으로 둔다
const CATS = ['시즌 계급', '통합 계급', '랭크전', '공식 클랜', '클랜']
const cat = ref('시즌 계급')
const mode = ref('솔로')
const sections = ref<Record<string, RankingItem[]>>({})
+72 -14
View File
@@ -1,34 +1,78 @@
<script setup lang="ts">
// 시즌 현황 — 진행률 + 시즌 맵(5v5/3v3). sa-home.jsx SeasonCard 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/mock/sa'
// 시즌 현황 — 진행 중인 시즌(관리자 등록)의 진행률 + 시즌 맵(5v5/3v3). 활성 시즌이 없으면 빈 상태.
import { computed, onMounted, ref } from 'vue'
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
import { toKstDate } from '@/shared/utils/format'
import UiPanel from '@/shared/components/UiPanel.vue'
const s = SA.season
const mapTab = ref('5vs5')
const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
const { getActiveSeason } = useSaSeason()
const season = ref<SaSeason | null>(null)
const mapTab = ref<'5vs5' | '3vs3'>('5vs5')
const maps = computed<string[]>(() => {
const s = season.value
if (!s) return []
return mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3
})
// 진행률(%) — 시작~종료 중 오늘 위치
const progress = computed(() => {
const s = season.value
if (!s) return 0
const start = new Date(s.startDate).getTime()
const end = new Date(s.endDate).getTime()
const now = Date.now()
if (now <= start) return 0
if (now >= end) return 100
return Math.round(((now - start) / (end - start)) * 100)
})
// 종료까지 남은 일수 (KST 일자 차이)
const dday = computed(() => {
const s = season.value
if (!s) return ''
const today = toKstDate(new Date())
const diff = Math.round(
(new Date(s.endDate).getTime() - new Date(today).getTime()) / 86400000,
)
if (diff > 0) return `D-${diff}`
if (diff === 0) return 'D-DAY'
return '종료'
})
// 시즌 배지 — 이름에서 '시즌 N' 또는 끝 숫자 추출 (없으면 SE)
const badge = computed(() => {
const name = season.value?.name ?? ''
const m = name.match(/시즌\s*(\d+)/) ?? name.match(/(\d+)\s*$/)
return m ? 'S' + m[1] : 'SE'
})
onMounted(async () => {
season.value = await getActiveSeason()
})
</script>
<template>
<UiPanel
v-if="season"
title="시즌 현황"
en="SEASON"
body-pad="14px 14px"
>
<template #right>
<span class="sc__dday num">{{ s.dday }}</span>
<span class="sc__dday num">{{ dday }}</span>
</template>
<div class="sc__head">
<div class="sc__badge num">
S2
{{ badge }}
</div>
<div class="sc__id">
<div class="sc__name">
{{ s.name }}
{{ season.name }}
</div>
<div class="sc__period">
{{ s.period }} 종료
{{ season.endDate }} 종료
</div>
</div>
</div>
@@ -36,11 +80,11 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
<div class="sc__bar">
<div
class="sc__fill"
:style="{ width: s.progress + '%' }"
:style="{ width: progress + '%' }"
/>
</div>
<div class="sc__progress">
<span>시즌 진행</span><span class="num sc__pct">{{ s.progress }}%</span>
<span>시즌 진행</span><span class="num sc__pct">{{ progress }}%</span>
</div>
<div class="sc__maps">
@@ -54,19 +98,28 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
:key="t"
class="sc__toggle-btn num"
:class="{ 'sc__toggle-btn--on': mapTab === t, 'sc__toggle-btn--sep': i > 0 }"
@click="mapTab = t"
@click="mapTab = t as '5vs5' | '3vs3'"
>
{{ t }}
</button>
</div>
</div>
<div class="sc__maplist">
<div
v-if="maps.length"
class="sc__maplist"
>
<span
v-for="m in maps"
:key="m"
class="sc__map"
>{{ m }}</span>
</div>
<div
v-else
class="sc__maps-empty"
>
등록된 {{ mapTab }} 맵이 없습니다.
</div>
</div>
</UiPanel>
</template>
@@ -184,4 +237,9 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
color: var(--t2);
font-weight: 500;
}
.sc__maps-empty {
font-size: 0.71875rem;
color: var(--t3);
padding: 4px 0;
}
</style>
+23 -2
View File
@@ -122,9 +122,17 @@ function recStr(d: ClanRecord) {
:size="14"
stroke="var(--t3)"
/>
<span class="cr__period-range num">{{ pd.from }} ~ {{ pd.to }}</span>
<span class="cr__period-range num">{{ pd.from }} ~ <span
v-if="pd.ongoing"
class="cr__period-now"
>진행중</span><template v-else>{{ pd.to }}</template></span>
<span
v-if="pd.months > 0"
v-if="pd.estimated"
class="cr__period-est"
title="최초 조회 시점 기준 — 실제 가입일은 더 이전일 수 있습니다"
>가입일 추정</span>
<span
v-if="pd.months > 0 && !pd.ongoing"
class="cr__period-months"
> {{ pd.months }}개월</span>
</div>
@@ -234,8 +242,21 @@ function recStr(d: ClanRecord) {
color: var(--ink);
font-weight: 700;
}
.cr__period-now {
color: var(--point);
font-weight: 700;
font-family: var(--font-body);
}
.cr__period-months {
font-size: 0.75rem;
color: var(--t3);
}
.cr__period-est {
font-size: 0.65625rem;
font-weight: 700;
color: var(--t3);
border: 1px solid var(--line2);
border-radius: 3px;
padding: 1px 6px;
}
</style>
+50 -5
View File
@@ -13,7 +13,21 @@ const props = defineProps<Props>()
const CAP = 8
const showAll = ref(false)
const all = props.p.mapRecords
const list = computed(() => (showAll.value ? all : all.slice(0, CAP)))
// 정렬 기준 — 매치수 / 승률 / 킬뎃 (모두 내림차순)
type SortKey = 'matches' | 'wr' | 'kd'
const SORTS: { key: SortKey; label: string }[] = [
{ key: 'matches', label: '매치수' },
{ key: 'wr', label: '승률' },
{ key: 'kd', label: '킬뎃' },
]
const sortKey = ref<SortKey>('matches')
const sorted = computed(() =>
[...all].sort((a, b) => b[sortKey.value] - a[sortKey.value]),
)
const list = computed(() =>
showAll.value ? sorted.value : sorted.value.slice(0, CAP),
)
function recStr(d: MapRecord) {
return `${d.matches}${d.w}${d.l}`
@@ -27,7 +41,23 @@ function recStr(d: MapRecord) {
body-pad="0"
>
<template #right>
<span class="mr__hint">매치 많은 · {{ all.length }} </span>
<div
class="mr__sorts"
role="group"
aria-label="맵별 전적 정렬"
>
<button
v-for="s in SORTS"
:key="s.key"
type="button"
class="mr__sort"
:class="{ 'mr__sort--on': sortKey === s.key }"
:aria-pressed="sortKey === s.key"
@click="sortKey = s.key"
>
{{ s.label }}
</button>
</div>
</template>
<div class="rectable-scroll">
@@ -95,10 +125,25 @@ function recStr(d: MapRecord) {
</template>
<style scoped>
.mr__hint {
.mr__sorts {
display: flex;
gap: 4px;
}
.mr__sort {
padding: 4px 9px;
border-radius: var(--r-pill);
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
font-weight: 700;
cursor: pointer;
}
.mr__sort--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.rectable tbody tr {
border-bottom: 1px solid var(--line3);
+97 -21
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
// 매치 기록 — 모드 필터 + 매치 행 목록. sa-profile-sections.jsx MatchHistory 포팅.
// 보이는 매치는 맵 이름을 반드시 갖도록, 노출 전에 상세(맵)를 동기로 확보한다.
import { computed, ref, watch } from 'vue'
import type { Player } from '@/shared/mock/types'
import { useSaMatch } from '@/composables/useSaMatch'
import type { Player, MatchRecord } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import MatchRow from './MatchRow.vue'
@@ -10,6 +12,8 @@ interface Props {
}
const props = defineProps<Props>()
const { ensureMatchMaps } = useSaMatch()
const TABS = ['전체', '클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트']
const tab = ref('전체')
// 다른 유저로 전환 시 탭 초기화
@@ -18,18 +22,65 @@ watch(() => props.p.name, () => (tab.value = '전체'))
// 더보기 페이지 크기 — 개발 4개 / 운영 20개
const PAGE_SIZE = import.meta.env.DEV ? 4 : 20
const limit = ref(PAGE_SIZE)
// 유저/탭 전환 시 노출 개수 초기화
watch([() => props.p.name, tab], () => (limit.value = PAGE_SIZE))
// 확보된 맵 캐시 (matchId → 맵). 빈 문자열도 '조회 완료'로 간주해 재요청을 막는다.
const resolvedMaps = ref<Record<string, string>>({})
// 첫 페이지(또는 탭 전환) 상세 적재 대기 상태 — 완료 전까지 섹션 로딩 표시
const ready = ref(false)
// 더보기 적재 진행 상태
const moreLoading = ref(false)
const ms = computed(() =>
tab.value === '전체' ? props.p.matches : props.p.matches.filter((m) => m.mode === tab.value),
)
const wins = computed(() => ms.value.filter((m) => m.win).length)
const visible = computed(() => ms.value.slice(0, limit.value))
// 표시용 매치 — 확보된 맵을 주입(원본 map 이 비어 있으면 resolvedMaps 로 채움)
const visible = computed<MatchRecord[]>(() =>
ms.value.slice(0, limit.value).map((m) => ({
...m,
map: m.map || resolvedMaps.value[m.matchId] || '',
})),
)
const hasMore = computed(() => ms.value.length > limit.value)
function showMore() {
// 주어진 매치 목록 중 맵 미확보분의 상세를 적재해 resolvedMaps 에 반영
async function ensureMaps(list: MatchRecord[]) {
const ids = list
.filter((m) => m.matchId && !m.map && !(m.matchId in resolvedMaps.value))
.map((m) => m.matchId)
if (!ids.length) return
const maps = await ensureMatchMaps(ids)
// 조회 결과 반영 — 끝내 맵이 없으면 ''로 마킹해 무한 재요청 방지
const next = { ...resolvedMaps.value }
for (const id of ids) next[id] = maps[id] ?? ''
resolvedMaps.value = next
}
// 첫 페이지 적재 — 완료 전까지 ready=false (섹션 로딩 표시)
async function loadFirst() {
ready.value = false
await ensureMaps(ms.value.slice(0, limit.value))
ready.value = true
}
// 유저/탭 전환 시 노출 개수 초기화 + 첫 페이지 재적재
watch(
[() => props.p.name, tab],
() => {
limit.value = PAGE_SIZE
void loadFirst()
},
{ immediate: true },
)
// 더보기 — 다음 페이지 맵을 먼저 적재한 뒤 노출(— 깜빡임 방지)
async function showMore() {
if (moreLoading.value) return
moreLoading.value = true
await ensureMaps(ms.value.slice(limit.value, limit.value + PAGE_SIZE))
limit.value += PAGE_SIZE
moreLoading.value = false
}
</script>
@@ -57,25 +108,40 @@ function showMore() {
</button>
</div>
<MatchRow
v-for="(m, i) in visible"
:key="m.matchId || i"
:m="m"
:last="!hasMore && i === visible.length - 1"
/>
<!-- 페이지 상세() 적재 완료 전까지 섹션 로딩 -->
<div
v-if="ms.length === 0"
class="mh__empty"
v-if="!ready"
class="mh__loading"
>
해당 모드의 최근 매치가 없어요.
매치 정보를 불러오는 ...
</div>
<button
v-if="hasMore"
class="mh__more"
@click="showMore"
>
더보기 <span class="mh__more-count num">{{ visible.length }} / {{ ms.length }}</span>
</button>
<template v-else>
<MatchRow
v-for="(m, i) in visible"
:key="m.matchId || i"
:m="m"
:last="!hasMore && i === visible.length - 1"
/>
<div
v-if="ms.length === 0"
class="mh__empty"
>
해당 모드의 최근 매치가 없어요.
</div>
<button
v-if="hasMore"
class="mh__more"
:disabled="moreLoading"
@click="showMore"
>
<template v-if="moreLoading">
불러오는 중...
</template>
<template v-else>
더보기 <span class="mh__more-count num">{{ visible.length }} / {{ ms.length }}</span>
</template>
</button>
</template>
</UiPanel>
</template>
@@ -116,6 +182,12 @@ function showMore() {
font-size: 0.8125rem;
color: var(--t3);
}
.mh__loading {
padding: 26px 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
.mh__more {
display: block;
width: 100%;
@@ -132,6 +204,10 @@ function showMore() {
.mh__more:hover {
background: #f7f8f9;
}
.mh__more:disabled {
cursor: default;
color: var(--t3);
}
.mh__more-count {
margin-left: 4px;
font-size: 0.6875rem;
+13 -5
View File
@@ -3,6 +3,7 @@
// 상세(맵/참가자)는 클릭(더보기) 시 넥슨 match-detail API로 lazy 로드한다.
import { computed, ref } from 'vue'
import { useSaMatch, type SaParticipant } from '@/composables/useSaMatch'
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
import { makeRank } from '@/shared/utils/adapt'
import type { MatchRecord, RosterPlayer } from '@/shared/mock/types'
import KdaChip from '@/shared/components/KdaChip.vue'
@@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>()
const { getMatchDetail } = useSaMatch()
const { getMeta, imageOf } = useSaMeta()
const open = ref(false)
const loading = ref(false)
const loaded = ref(false)
@@ -29,9 +31,10 @@ const pct = computed(() => {
})
// 매치 상세 참가자 → 기존 UI 로스터(RosterPlayer)
function toRoster(p: SaParticipant): RosterPlayer {
// 계급은 시즌 계급 기준이며, 메타에서 시즌 계급 이미지를 해석해 뱃지에 표시한다.
function toRoster(p: SaParticipant, meta: SaMetaSections): RosterPlayer {
return {
rank: makeRank(p.seasonGrade),
rank: makeRank(p.seasonGrade, imageOf(meta, 'season_grade', p.seasonGrade)),
name: p.userName,
isOwner: false,
k: p.kill,
@@ -47,11 +50,16 @@ async function toggle() {
// 처음 펼칠 때만 상세 1회 로드 (matchId 없으면 스킵)
if (!open.value || loaded.value || !props.m.matchId) return
loading.value = true
const detail = await getMatchDetail(props.m.matchId)
// 상세와 메타(계급 이미지 해석용)를 함께 로드 — 메타는 캐시되어 사실상 1회만 호출
const [detail, meta] = await Promise.all([getMatchDetail(props.m.matchId), getMeta()])
if (detail) {
mapName.value = detail.matchMap || props.m.map
winTeam.value = detail.participants.filter((x) => x.matchResult === '1').map(toRoster)
loseTeam.value = detail.participants.filter((x) => x.matchResult !== '1').map(toRoster)
winTeam.value = detail.participants
.filter((x) => x.matchResult === '1')
.map((p) => toRoster(p, meta))
loseTeam.value = detail.participants
.filter((x) => x.matchResult !== '1')
.map((p) => toRoster(p, meta))
}
loaded.value = true
loading.value = false
+99 -7
View File
@@ -10,14 +10,27 @@ import AppIcon from '@/shared/components/AppIcon.vue'
interface Props {
p: Player
// 전적 갱신 진행 중 여부
refreshing?: boolean
}
const props = defineProps<Props>()
const scope = ref('통합')
const emit = defineEmits<{
(e: 'refresh'): void
}>()
// 계급 표시는 시즌 계급 우선 — 기본 탭을 '시즌'으로 둔다
const scope = ref('시즌')
const selRank = computed(() => (scope.value === '통합' ? props.p.rankTotal : props.p.rankSeason))
const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal : props.p.rankNoSeason))
const stats = computed(() => [
interface Stat {
lab: string
val: string
col: string
tip?: string
}
const stats = computed<Stat[]>(() => [
{ lab: '승률', val: props.p.wr + '%', col: 'var(--ink)' },
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
@@ -25,7 +38,12 @@ const stats = computed(() => [
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
{ lab: '랭킹', val: SA.fmt(selRankNo.value) + '위', col: 'var(--point)' },
{ lab: '매너', val: props.p.mannerGrade ?? '-', col: 'var(--ink)' },
{ lab: '접속률', val: props.p.connRate + '%', col: 'var(--ink)' },
{
lab: '접속률',
val: props.p.connRate + '%',
col: 'var(--ink)',
tip: '최근 90일 중 매치 기록이 있는 날의 비율입니다. (집계된 매치 기준)',
},
])
</script>
@@ -39,7 +57,7 @@ const stats = computed(() => [
<div class="ps__rank">
<SegTabs
v-model="scope"
:tabs="['통합', '시즌']"
:tabs="['시즌', '통합']"
/>
<div class="ps__badge">
<RankBadge
@@ -76,12 +94,17 @@ const stats = computed(() => [
stroke="#fff"
/> 즐겨찾기
</button>
<button class="ps__btn ps__btn--ghost">
<button
class="ps__btn ps__btn--ghost"
:disabled="props.refreshing"
@click="emit('refresh')"
>
<AppIcon
name="refresh"
:size="14"
stroke="#3a4049"
/> 전적 갱신
:class="{ 'ps__btn-spin': props.refreshing }"
/> {{ props.refreshing ? '갱신 중…' : '전적 갱신' }}
</button>
</div>
</div>
@@ -110,7 +133,19 @@ const stats = computed(() => [
:class="{ 'ps__stat--alt': i % 2 === 0, 'ps__stat--sep': i > 0 }"
>
<div class="ps__stat-lab">
{{ s.lab }}
{{ s.lab }}<span
v-if="s.tip"
class="ps__tip"
tabindex="0"
role="note"
:aria-label="`${s.lab}: ${s.tip}`"
>
<span
class="ps__tip-icon"
aria-hidden="true"
>?</span>
<span class="ps__tip-box">{{ s.tip }}</span>
</span>
</div>
<div
class="ps__stat-val num"
@@ -209,6 +244,18 @@ const stats = computed(() => [
color: #3a4049;
font-weight: 600;
}
.ps__btn:disabled {
opacity: 0.65;
cursor: default;
}
.ps__btn-spin {
animation: ps-spin 0.8s linear infinite;
}
@keyframes ps-spin {
to {
transform: rotate(360deg);
}
}
.ps__rings {
display: flex;
gap: 14px;
@@ -236,6 +283,51 @@ const stats = computed(() => [
font-weight: 600;
margin-bottom: 5px;
}
.ps__tip {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 3px;
cursor: help;
vertical-align: middle;
}
.ps__tip-icon {
display: inline-grid;
place-items: center;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
background: var(--line2);
color: var(--surf);
font-size: 0.5625rem;
font-weight: 800;
line-height: 1;
}
.ps__tip-box {
position: absolute;
bottom: calc(100% + 0.375rem);
right: 0;
width: 11.25rem;
padding: 0.5rem 0.625rem;
background: var(--ink);
color: #fff;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.45;
text-align: left;
border-radius: var(--r-xs);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
transition: opacity 0.12s;
z-index: 20;
}
.ps__tip:hover .ps__tip-box,
.ps__tip:focus .ps__tip-box,
.ps__tip:focus-visible .ps__tip-box {
opacity: 1;
visibility: visible;
}
.ps__stat-val {
font-weight: 700;
font-size: 0.90625rem;
+34 -18
View File
@@ -13,12 +13,15 @@ interface Props {
const props = defineProps<Props>()
const seasons = props.p.rankedSeasons
const sel = ref(seasons[0]?.season ?? '통산')
// 기본 선택 — 현재 활성 시즌 우선, 없으면 가장 최근 시즌
const sel = ref((seasons.find((s) => s.current) ?? seasons[0])?.season ?? '통산')
const isAll = computed(() => sel.value === '통산')
const cur = computed(() => seasons.find((s) => s.season === sel.value) ?? seasons[0])
// 티어/티어점수 표기 — 현재 시즌(라이브) 또는 종료 스냅샷 보유 과거 시즌만. 통산은 '—'
const showTier = computed(() => !isAll.value && !!cur.value?.hasTier)
// 통산 집계(매치 가중 평균)
function agg(key: 'solo' | 'party') {
function agg(key: 'solo' | 'party' | 'clan') {
let M = 0, W = 0, L = 0, kdW = 0, hsW = 0
seasons.forEach((s) => {
const d = s[key]
@@ -37,11 +40,24 @@ function agg(key: 'solo' | 'party') {
} as RankedRecord
}
const rows = computed<[string, RankedRecord][]>(() => {
// 행 메타 — 클랜 랭크전은 티어/티어점수 데이터가 없어 항상 '—'로 표기(hasTier=false)
interface RankRow {
label: string
rec: RankedRecord
hasTier: boolean
}
// 통산 집계값 선택 — 실데이터는 rankedCareer(기간 무관 전체 합산), 없으면(mock) 시즌 합산
function careerRec(key: 'solo' | 'party' | 'clan'): RankedRecord {
return props.p.rankedCareer ? props.p.rankedCareer[key] : agg(key)
}
const rows = computed<RankRow[]>(() => {
const c = cur.value
const pick = (key: 'solo' | 'party' | 'clan') =>
isAll.value ? careerRec(key) : (c?.[key] as RankedRecord)
return [
['솔로', isAll.value ? agg('solo') : (c?.solo as RankedRecord)],
['파티', isAll.value ? agg('party') : (c?.party as RankedRecord)],
{ label: '솔로', rec: pick('solo'), hasTier: true },
{ label: '파티', rec: pick('party'), hasTier: true },
{ label: '클랜', rec: pick('clan'), hasTier: false },
]
})
@@ -104,43 +120,43 @@ function recStr(d: RankedRecord) {
</thead>
<tbody>
<tr
v-for="([label, d], i) in rows"
:key="label"
v-for="(row, i) in rows"
:key="row.label"
:class="{ 'rs__row--alt': i % 2 === 1 }"
>
<td class="td-strong">
{{ label }} 랭크전
{{ row.label }} 랭크전
</td>
<td>
<span
v-if="isAll"
v-if="!showTier || !row.hasTier"
class="rs__dash"
></span>
<TierChip
v-else
:name="d.tier"
:div="d.div"
:image="d.tierImage"
:name="row.rec.tier"
:div="row.rec.div"
:image="row.rec.tierImage"
/>
</td>
<td class="ta-r td-num">
<span
v-if="isAll"
v-if="!showTier || !row.hasTier"
class="rs__dash"
></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> RP</span></span>
<span v-else>{{ SA.fmt(row.rec.points) }}<span class="rs__unit"> RP</span></span>
</td>
<td class="ta-r td-num">
{{ d.matches }}
{{ row.rec.matches }}
</td>
<td class="ta-r td-rec">
{{ recStr(d) }}
{{ recStr(row.rec) }}
</td>
<td class="ta-r">
<WrCell :wr="d.wr" />
<WrCell :wr="row.rec.wr" />
</td>
<td class="ta-r td-kd">
{{ d.kd }}%
{{ row.rec.kd }}%
</td>
</tr>
</tbody>
+5 -3
View File
@@ -10,7 +10,9 @@ interface Props {
}
const props = defineProps<Props>()
const recent = computed(() => props.p.matches.slice(0, 12))
// 최근 N전 기준 (승/패 흐름은 1줄 10개씩 표시)
const RECENT_LIMIT = 20
const recent = computed(() => props.p.matches.slice(0, RECENT_LIMIT))
const wins = computed(() => recent.value.filter((m) => m.win).length)
const losses = computed(() => recent.value.length - wins.value)
const wr = computed(() => Math.round((wins.value / recent.value.length) * 100))
@@ -79,11 +81,11 @@ const flow = computed(() => recent.value.slice().reverse())
color: var(--negative);
}
.rt__flow {
display: flex;
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.rt__cell {
flex: 1;
height: 20px;
border-radius: 3px;
display: grid;
+26 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 매치 상세 팀 테이블 — 승/패 팀 로스터. sa-profile-sections.jsx TeamTable 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/mock/sa'
import type { RosterPlayer } from '@/shared/mock/types'
import RankBadge from '@/shared/components/RankBadge.vue'
@@ -11,9 +12,17 @@ interface Props {
}
defineProps<Props>()
const router = useRouter()
function kdPct(pl: RosterPlayer) {
return Math.round((pl.k / (pl.k + pl.d)) * 100)
}
// 참가자 닉네임 클릭 시 해당 유저 전적으로 이동 (이름이 없으면 무시)
function openPlayer(name: string) {
if (!name) return
router.push({ name: 'profile', params: { name } })
}
</script>
<template>
@@ -66,7 +75,12 @@ function kdPct(pl: RosterPlayer) {
/>
<span
class="tt__pname"
:class="{ 'tt__pname--link': pl.name }"
:style="{ fontWeight: pl.isOwner ? 800 : 600, color: pl.isOwner ? color : 'var(--ink)' }"
:role="pl.name ? 'link' : undefined"
:tabindex="pl.name ? 0 : undefined"
@click="openPlayer(pl.name)"
@keydown.enter="openPlayer(pl.name)"
>
<span
v-if="pl.isOwner"
@@ -87,7 +101,7 @@ function kdPct(pl: RosterPlayer) {
{{ kdPct(pl) }}%
</td>
<td class="tt__td ta-r num tt__hs">
{{ pl.hs }}%
{{ pl.hs }}
</td>
<td class="tt__td ta-r num tt__dmg">
{{ SA.fmt(pl.dmg) }}
@@ -169,6 +183,17 @@ tbody tr:last-child .tt__td {
overflow: hidden;
text-overflow: ellipsis;
}
.tt__pname--link {
cursor: pointer;
}
.tt__pname--link:hover {
text-decoration: underline;
}
.tt__pname--link:focus-visible {
outline: 2px solid var(--point);
outline-offset: 2px;
border-radius: 2px;
}
.tt__me {
font-size: 0.59375rem;
font-weight: 800;
+117 -215
View File
@@ -1,53 +1,113 @@
<script setup lang="ts">
// 최근 동향 추세 그래프 — 승률/킬뎃 시계열 (일별/주별). sa-profile-sections.jsx TrendChart 포팅.
import { computed, ref } from 'vue'
import type { Player, TrendPoint } from '@/shared/mock/types'
// 최근 동향 추세 그래프 — 20경기 단위 버킷의 승률/킬뎃 추이(최대 200경기). ApexCharts 사용.
// 툴팁에 각 구간의 승률·킬뎃 값과 직전 구간 대비 증감(▲/▼)을 함께 표기한다.
import { computed } from 'vue'
import type { ApexOptions } from 'apexcharts'
import VueApexCharts from 'vue3-apexcharts'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const period = ref('주별')
const data = computed<TrendPoint[]>(() =>
period.value === '일별' ? props.p.trend.daily : props.p.trend.weekly,
)
const WR_COLOR = '#2f6fed'
const KD_COLOR = '#48515f'
const UP = '#16a34a'
const DOWN = '#e5484d'
const MUTED = '#949aa3'
const W = 720, H = 250, padL = 38, padR = 16, padT = 18, padB = 34
const iw = W - padL - padR
const ih = H - padT - padB
const data = computed(() => props.p.trend)
const n = computed(() => data.value.length)
function x(i: number) {
return padL + (n.value === 1 ? iw / 2 : (i / (n.value - 1)) * iw)
}
function y(v: number) {
return padT + (1 - (v - 20) / (90 - 20)) * ih
}
function path(key: 'wr' | 'kd') {
return data.value.map((d, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(d[key]).toFixed(1)}`).join(' ')
}
const areaWr = computed(() => {
const last = n.value - 1
return `${path('wr')} L${x(last).toFixed(1)} ${(padT + ih).toFixed(1)} L${x(0).toFixed(1)} ${(padT + ih).toFixed(1)} Z`
})
const series = computed(() => [
{ name: '승률', data: data.value.map((d) => d.wr) },
{ name: '킬뎃', data: data.value.map((d) => d.kd) },
])
const grid = [20, 40, 60, 80]
const last = computed(() => data.value[n.value - 1] as TrendPoint)
const first = computed(() => data.value[0] as TrendPoint)
const labelStep = computed(() => Math.ceil(n.value / 8))
function delta(key: 'wr' | 'kd') {
return last.value[key] - first.value[key]
// 직전 구간 대비 증감 배지 HTML (첫 구간은 비교 대상 없음)
function deltaBadge(val: number, prev: number | undefined): string {
if (prev === undefined) return ''
const d = val - prev
const arrow = d > 0 ? '▲' : d < 0 ? '▼' : ''
const color = d > 0 ? UP : d < 0 ? DOWN : MUTED
return `<span style="color:${color};font-weight:700;margin-left:6px">${arrow}${Math.abs(d)}</span>`
}
const xLabels = computed(() =>
data.value
.map((d, i) => ({ d, i }))
.filter(({ i }) => i % labelStep.value === 0 || i === n.value - 1),
)
const points = computed(() => data.value.map((d, i) => ({ d, i, isLast: i === n.value - 1 })))
function tipRow(
label: string,
color: string,
val: number,
prev: number | undefined,
): string {
return `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0"></span>
<span style="color:#6b7280">${label}</span>
<b style="color:#1f2937;margin-left:auto">${val}%</b>${deltaBadge(val, prev)}
</div>`
}
const options = computed<ApexOptions>(() => ({
chart: {
type: 'line',
height: 280,
fontFamily: 'inherit',
toolbar: { show: false },
zoom: { enabled: false },
animations: { enabled: true, speed: 350 },
},
colors: [WR_COLOR, KD_COLOR],
stroke: { curve: 'smooth', width: 3 },
markers: {
size: 4,
strokeWidth: 2,
strokeColors: '#fff',
hover: { size: 7 },
},
dataLabels: { enabled: false },
grid: {
borderColor: '#eceef1',
padding: { left: 6, right: 10, top: 0 },
},
legend: {
position: 'top',
horizontalAlign: 'left',
fontSize: '12px',
fontWeight: 600,
markers: { size: 6 },
itemMargin: { horizontal: 10 },
},
xaxis: {
categories: data.value.map((d) => d.label),
axisBorder: { show: false },
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { style: { colors: MUTED, fontSize: '11px' } },
},
yaxis: {
// 0~100 고정 대신 데이터 범위에 맞춰 자동 스케일 → 구간별 등락이 잘 보이게 (정확한 값은 툴팁)
forceNiceScale: true,
labels: {
style: { colors: MUTED, fontSize: '11px' },
formatter: (v: number) => `${Math.round(v)}`,
},
},
tooltip: {
shared: true,
intersect: false,
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
const cur = data.value[dataPointIndex]
if (!cur) return ''
const prev = dataPointIndex > 0 ? data.value[dataPointIndex - 1] : undefined
return `<div style="padding:8px 11px;font-size:12px;font-family:inherit;min-width:9.5rem">
<div style="font-weight:700;color:#1f2937">${cur.label}경기</div>
${tipRow('승률', WR_COLOR, cur.wr, prev?.wr)}
${tipRow('킬뎃', KD_COLOR, cur.kd, prev?.kd)}
</div>`
},
},
}))
</script>
<template>
@@ -57,193 +117,35 @@ const points = computed(() => data.value.map((d, i) => ({ d, i, isLast: i === n.
body-pad="14px 16px 16px"
>
<template #right>
<SegTabs
v-model="period"
:tabs="['일별', '주별']"
/>
<span class="tc__cap">20경기 단위 · 오른쪽이 최신</span>
</template>
<div class="tc__legend">
<div class="tc__chip">
<span class="tc__line tc__line--wr" />
<span class="tc__lab">승률</span>
<span class="num tc__val tc__val--wr">{{ last.wr }}%</span>
<span
class="num tc__delta"
:class="delta('wr') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
>
{{ delta('wr') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('wr')) }}
</span>
</div>
<div class="tc__chip">
<span class="tc__line tc__line--kd" />
<span class="tc__lab">킬뎃</span>
<span class="num tc__val tc__val--kd">{{ last.kd }}%</span>
<span
class="num tc__delta"
:class="delta('kd') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
>
{{ delta('kd') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('kd')) }}
</span>
</div>
<span class="tc__period">{{ period === '일별' ? '최근 14일' : '최근 10주' }} 추이</span>
</div>
<svg
:viewBox="`0 0 ${W} ${H}`"
class="tc__svg"
<VueApexCharts
v-if="n"
type="line"
height="280"
:options="options"
:series="series"
/>
<div
v-else
class="tc__empty"
>
<defs>
<linearGradient
id="wrGrad"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stop-color="var(--accent-blue)"
stop-opacity="0.16"
/>
<stop
offset="100%"
stop-color="var(--accent-blue)"
stop-opacity="0"
/>
</linearGradient>
</defs>
<g
v-for="g in grid"
:key="g"
>
<line
:x1="padL"
:y1="y(g)"
:x2="W - padR"
:y2="y(g)"
stroke="#eceef1"
stroke-width="1"
/>
<text
:x="padL - 8"
:y="y(g) + 3.5"
text-anchor="end"
font-size="10.5"
fill="#949aa3"
font-family="'Chakra Petch',sans-serif"
>{{ g }}</text>
</g>
<text
v-for="{ d, i } in xLabels"
:key="'lab' + i"
:x="x(i)"
:y="H - 12"
text-anchor="middle"
font-size="10.5"
fill="#949aa3"
>{{ d.label }}</text>
<path
:d="areaWr"
fill="url(#wrGrad)"
/>
<path
:d="path('wr')"
fill="none"
stroke="var(--accent-blue)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<path
:d="path('kd')"
fill="none"
stroke="var(--point)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<g
v-for="{ d, i, isLast } in points"
:key="'p' + i"
>
<circle
:cx="x(i)"
:cy="y(d.wr)"
:r="isLast ? 4 : 2.6"
fill="#fff"
stroke="var(--accent-blue)"
stroke-width="2"
/>
<circle
:cx="x(i)"
:cy="y(d.kd)"
:r="isLast ? 4 : 2.6"
fill="#fff"
stroke="var(--point)"
stroke-width="2"
/>
</g>
</svg>
최근 동향은 20경기 이상부터 표시됩니다.
</div>
</UiPanel>
</template>
<style scoped>
.tc__legend {
display: flex;
gap: 20px;
margin-bottom: 10px;
padding-left: 4px;
}
.tc__chip {
display: flex;
align-items: center;
gap: 7px;
}
.tc__line {
width: 11px;
height: 3px;
border-radius: 2px;
}
.tc__line--wr {
background: var(--accent-blue);
}
.tc__line--kd {
background: var(--point);
}
.tc__lab {
font-size: 0.75rem;
color: var(--t2);
font-weight: 600;
}
.tc__val {
font-weight: 700;
font-size: 0.8125rem;
}
.tc__val--wr {
color: var(--accent-blue);
}
.tc__val--kd {
color: var(--point);
}
.tc__delta {
font-weight: 700;
font-size: 0.71875rem;
}
.tc__delta--up {
color: var(--up);
}
.tc__delta--down {
color: var(--negative);
}
.tc__period {
margin-left: auto;
.tc__cap {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.tc__svg {
width: 100%;
height: auto;
display: block;
.tc__empty {
padding: 2.5rem 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
</style>
+14
View File
@@ -32,6 +32,20 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ClanHomePage.vue'),
meta: { requiresAuth: false },
},
{
// 관리자 — 시즌 관리 (TODO: 인증 도입 시 관리자 권한 가드 적용)
path: '/admin/seasons',
name: 'admin-seasons',
component: () => import('@/pages/admin/AdminSeasonsPage.vue'),
meta: { requiresAuth: true },
},
{
// 관리자 — 맵 관리
path: '/admin/maps',
name: 'admin-maps',
component: () => import('@/pages/admin/AdminMapsPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
@@ -0,0 +1,181 @@
<script setup lang="ts">
// 개발 전용 — 매치 동기화 크론을 수동 트리거하고 적재 현황을 보여주는 플로팅 위젯.
// App.vue 에서 import.meta.env.DEV 일 때만 마운트된다.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useSaMatch, type DevStats } from '@/composables/useSaMatch'
const { devSync, devStats } = useSaMatch()
const loading = ref(false)
const status = ref('')
const stats = ref<DevStats | null>(null)
// 동기화 직전 상세 적재 수 — 이후 증가분(델타) 표기용
const beforeDetails = ref(0)
const showDelta = ref(false)
const timers: ReturnType<typeof setTimeout>[] = []
function clearTimers() {
timers.forEach((t) => clearTimeout(t))
timers.length = 0
}
async function loadStats() {
const s = await devStats()
if (s) stats.value = s
}
const delta = computed(() =>
showDelta.value && stats.value ? stats.value.details - beforeDetails.value : 0,
)
async function run() {
if (loading.value) return
loading.value = true
status.value = ''
showDelta.value = false
// 동기화 직전 현황을 새로 조회해 기준값을 고정한다 (델타가 절대값이 되는 것을 방지).
await loadStats()
beforeDetails.value = stats.value?.details ?? 0
const ok = await devSync()
loading.value = false
status.value = ok ? '동기화 시작됨' : '실패'
clearTimers()
if (ok) {
// 백그라운드 진행을 반영하기 위해 잠시 후 현황을 몇 차례 갱신한다.
showDelta.value = true
;[3000, 8000, 15000].forEach((d) => timers.push(setTimeout(loadStats, d)))
}
// dev/sync 는 fire-and-forget — 완료 신호가 없으므로 상태 텍스트는 트리거 확인용으로만
// 잠시 띄우고 자동 제거한다. 실제 적재 결과는 현황의 +N(델타)로 확인.
timers.push(setTimeout(() => (status.value = ''), 4000))
}
onMounted(loadStats)
onUnmounted(clearTimers)
</script>
<template>
<div class="devsync">
<div class="devsync__stat">
<span>유저 <b>{{ stats?.users ?? '—' }}</b></span>
<span class="devsync__sep">·</span>
<span>상세 <b>{{ stats?.details ?? '—' }}</b></span>
<span
v-if="delta > 0"
class="devsync__delta"
>+{{ delta }}</span>
<span class="devsync__sep">·</span>
<span>목록 <b>{{ stats?.matches ?? '—' }}</b></span>
<span
v-if="stats && stats.failures > 0"
class="devsync__fail"
>실패 {{ stats.failures }}</span>
</div>
<div class="devsync__row">
<button
type="button"
class="devsync__btn"
:disabled="loading"
aria-label="개발 전용: 매치 동기화 크론 수동 실행"
title="개발 전용: 매치 동기화 크론 수동 실행"
@click="run"
>
{{ loading ? '동기화 중…' : 'DEV 동기화' }}
</button>
<button
type="button"
class="devsync__refresh"
aria-label="현황 새로고침"
title="현황 새로고침"
@click="loadStats"
>
</button>
<span
v-if="status"
class="devsync__status"
:class="{ 'devsync__status--err': status === '실패' }"
role="status"
>
{{ status }}
</span>
</div>
</div>
</template>
<style scoped>
.devsync {
position: fixed;
left: 1rem;
bottom: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
background: var(--surf);
border: 1px solid var(--line2);
border-radius: var(--r-sm);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.2);
}
.devsync__stat {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.71875rem;
color: var(--t2);
}
.devsync__stat b {
color: var(--ink);
font-weight: 700;
}
.devsync__delta {
color: var(--point);
font-weight: 700;
}
.devsync__sep {
color: var(--t3);
}
.devsync__fail {
color: var(--negative);
font-weight: 600;
}
.devsync__row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.devsync__btn {
padding: 0.5rem 0.75rem;
border: none;
border-radius: var(--r-xs);
background: var(--dark);
color: #fff;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
.devsync__btn:disabled {
opacity: 0.6;
cursor: default;
}
.devsync__refresh {
padding: 0.375rem 0.5rem;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
background: var(--surf);
color: var(--t2);
font-size: 0.8125rem;
cursor: pointer;
}
.devsync__status {
font-size: 0.71875rem;
font-weight: 700;
color: var(--point);
}
.devsync__status--err {
color: var(--negative);
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 상단 유틸 바 — 어두운 바. 팔레트 피커 + 로그인 + 문의.
// 상단 유틸 바 — 어두운 바. 팔레트 피커 + 관리자 + 로그인 + 문의.
import { RouterLink } from 'vue-router'
import { useUiStore } from '@/stores/ui.store'
import PalettePicker from '@/shared/components/PalettePicker.vue'
@@ -11,6 +12,13 @@ const ui = useUiStore()
<div class="topbar__inner">
<PalettePicker on-dark />
<span class="topbar__sep" />
<RouterLink
to="/admin/seasons"
class="topbar__link"
>
관리자
</RouterLink>
<span class="topbar__sep" />
<button
class="topbar__link"
@click="ui.openLogin()"
@@ -57,6 +65,7 @@ const ui = useUiStore()
border: none;
font-family: var(--font-body);
padding: 0;
text-decoration: none;
}
.topbar__link:hover {
color: #fff;
+2 -5
View File
@@ -171,7 +171,7 @@ function makeRankedSeasons(r: () => number): RankedSeason[] {
points: ri(r, 800, 2400),
}
}
return seasons.map((season) => ({ season, solo: mk(), party: mk() }))
return seasons.map((season) => ({ season, solo: mk(), party: mk(), clan: mk() }))
}
function makeRecords(r: () => number, names: string[]) {
@@ -243,10 +243,7 @@ function makePlayer(seed: PlayerSeed): Player {
}),
modeRecords: makeRecords(r, MODE_NAMES),
mapRecords: makeRecords(r, [...MAPS_5V5, ...MAPS_3V3]),
trend: {
daily: makeTrend(r, 14, false),
weekly: makeTrend(r, 12, true),
},
trend: makeTrend(r, 10, false),
together: (() => {
const games = ri(r, 8, 120)
const w = ri(r, Math.floor(games * 0.4), Math.floor(games * 0.6))
+15 -6
View File
@@ -95,6 +95,10 @@ export interface ClanRecord {
from: string
to: string
months: number
/** 진행 중 여부 (현재 소속 클랜 — 종료일 대신 '진행중' 표시) */
ongoing?: boolean
/** 가입일 추정 여부 (최초 관측 기간 — 실제 가입은 더 이전일 수 있음) */
estimated?: boolean
}[]
}
@@ -145,13 +149,19 @@ export interface RankedRecord {
export interface RankedSeason {
/** 시즌 식별자 (예: '2024 S1') */
season: string
/** 현재 진행 중인 시즌 여부 — 기본 선택용 */
current?: boolean
/** 티어/티어점수 표기 가능 여부 — 현재 시즌(라이브) 또는 종료 스냅샷 보유 과거 시즌만 true */
hasTier?: boolean
/** 솔로 랭크전 전적 */
solo: RankedRecord
/** 파티 랭크전 전적 */
party: RankedRecord
/** 클랜 랭크전 전적 (티어/티어점수 API 미제공 — 매치 기록으로 집계) */
clan: RankedRecord
}
/** 추세 그래프 1포인트 — Player.trend.daily / weekly 원소 */
/** 추세 그래프 1포인트 — Player.trend 원소 (20경기 버킷) */
export interface TrendPoint {
/** 승률(%) */
wr: number
@@ -209,17 +219,16 @@ export interface Player {
matches: MatchRecord[]
/** 랭크전 시즌 기록 */
rankedSeasons: RankedSeason[]
/** 랭크전 통산 — 기간 무관 전체 랭크전 매치 합산 (없으면 시즌 합산으로 폴백) */
rankedCareer?: RankedSeason
/** 클랜별 전적 */
clanRecords: ClanRecord[]
/** 모드별 전적 */
modeRecords: ModeRecord[]
/** 맵별 전적 */
mapRecords: MapRecord[]
/** 추세 (일별/주별) */
trend: {
daily: TrendPoint[]
weekly: TrendPoint[]
}
/** 최근 동향 — 20경기 단위 버킷(최대 200경기)의 승률·킬뎃 추이. 오래된→최신 순 */
trend: TrendPoint[]
/** 함께한 전적 (친구 목록 등에서 사용) */
together: {
games: number
+31
View File
@@ -18,3 +18,34 @@ export function formatDate(input: Date | string): string {
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
/**
* Date 또는 ISO 문자열을 KST(UTC+9) 기준 'YYYY-MM-DD'로 변환.
* 시즌/클랜 기간(관리자·스냅샷이 KST 일자)과 매치 일시(UTC 저장)를 같은 기준으로 비교할 때 사용.
*/
export function toKstDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10)
}
/**
* Date 또는 ISO 문자열을 현재 기준 상대 시간으로 변환
* (예: '방금 전', '5분 전', '3시간 전', '2일 전', '4개월 전', '1년 전')
* 상대 시간은 절대 시각 차이로 계산하므로 타임존 보정이 필요 없다.
*/
export function timeAgo(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
const sec = Math.floor((Date.now() - date.getTime()) / 1000)
if (sec < 60) return '방금 전'
const min = Math.floor(sec / 60)
if (min < 60) return `${min}분 전`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}시간 전`
const day = Math.floor(hour / 24)
if (day < 30) return `${day}일 전`
const month = Math.floor(day / 30)
if (month < 12) return `${month}개월 전`
return `${Math.floor(month / 12)}년 전`
}