UI 작업

This commit is contained in:
2026-06-12 01:54:26 +09:00
parent eaa30192cf
commit b8d636ba9d
122 changed files with 5683 additions and 5769 deletions
+57 -19
View File
@@ -119,7 +119,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -645,7 +644,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -694,7 +692,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -706,7 +703,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -719,7 +715,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1280,6 +1275,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1297,6 +1295,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1314,6 +1315,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1331,6 +1335,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1348,6 +1355,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1365,6 +1375,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1382,6 +1395,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1399,6 +1415,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1587,6 +1606,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1604,6 +1626,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1621,6 +1646,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1638,6 +1666,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1655,6 +1686,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1672,6 +1706,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1897,7 +1934,6 @@
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
@@ -2370,7 +2406,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.38",
"@vue/shared": "3.5.38"
@@ -2568,7 +2603,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38"
@@ -2639,7 +2673,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2862,7 +2895,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2917,9 +2949,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001797",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
@@ -3404,7 +3436,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3465,7 +3496,6 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
@@ -4706,6 +4736,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4727,6 +4760,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4748,6 +4784,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4769,6 +4808,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6013,7 +6055,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6163,7 +6204,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6297,7 +6337,6 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -6699,7 +6738,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-sfc": "3.5.38",
+20
View File
@@ -0,0 +1,20 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 반응형 뷰포트 훅 — 원본 sa-shared.jsx useBP() 포팅.
// CSS 미디어쿼리로 처리하기 어려운 JS 레벨 분기(엘리먼트 크기/prop 등)에 사용한다.
// breakpoint 체계: mobile < 760px, narrow < 600px (CSS @media 와 동일 기준)
export function useBreakpoint() {
const w = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1200)
function onResize() {
w.value = window.innerWidth
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
const mobile = computed(() => w.value < 760)
const narrow = computed(() => w.value < 600)
return { w, mobile, narrow }
}
+34
View File
@@ -0,0 +1,34 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// SA 소식 항목 (백엔드 /api/news/sections 크롤링 결과)
export interface NewsItem {
date: string
title: string
link: string
}
// 카테고리(공지사항/GM이야기/업데이트)별 소식 맵
export type NewsSections = Record<string, NewsItem[]>
export function useNews() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getSections(): Promise<NewsSections> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 객체만 반환
return (await api.get<NewsSections>('/news/sections')) as unknown as NewsSections
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return {}
} finally {
loading.value = false
}
}
return { loading, error, getSections }
}
+44
View File
@@ -0,0 +1,44 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// SA 랭킹 항목 (백엔드 /api/ranking/sections 크롤링 결과)
// 카테고리별로 사용하는 필드만 채워진다.
export interface RankingItem {
rankNo: number
name: string
gradeImg: string
tierImg: string
clanMark: string
winRate: string
kd: string
record: string
rp: string
playTime: string
score: string
master: string
}
// 카테고리 키: total_class | season_class | ranked_solo | ranked_party | ranked_clan | official_clan | clan
export type RankingSections = Record<string, RankingItem[]>
export function useRanking() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getSections(): Promise<RankingSections> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 객체만 반환
return (await api.get<RankingSections>('/ranking/sections')) as unknown as RankingSections
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return {}
} finally {
loading.value = false
}
}
return { loading, error, getSections }
}
+76
View File
@@ -0,0 +1,76 @@
import { useApi } from './useApi'
// 매치 목록 항목 (백엔드 MatchItemDto)
export interface SaMatchItem {
matchId: string
matchType: string
matchMode: string
dateMatch: string
matchResult: string
kill: number
death: number
assist: number
}
// 매치 상세 참가자
export interface SaParticipant {
teamId: string
matchResult: string
userName: string
seasonGrade: string
clanName: string
kill: number
death: number
headshot: number
damage: number
assist: number
}
// 매치 상세 (맵 + 참가자)
export interface SaMatchDetail {
matchId: string
matchType: string
matchMode: string
dateMatch: string
matchMap: string
participants: SaParticipant[]
}
// 매치 타입 (넥슨 match_type 과 동일 문자열)
export const MATCH_TYPES = [
'랭크전 솔로',
'랭크전 파티',
'퀵매치 클랜전',
'클랜 랭크전',
'토너먼트',
] as const
export function useSaMatch() {
const api = useApi()
// 매치 목록 (타입별). 실패 시 빈 배열
async function getMatches(
ouid: string,
matchType: string,
matchMode = '폭파미션',
): Promise<SaMatchItem[]> {
try {
return (await api.get('/sa-matches', {
params: { ouid, match_type: matchType, match_mode: matchMode },
})) as unknown as SaMatchItem[]
} catch {
return []
}
}
// 매치 상세 (더보기 시점에만 호출). 실패 시 null
async function getMatchDetail(matchId: string): Promise<SaMatchDetail | null> {
try {
return (await api.get(`/sa-matches/${matchId}`)) as unknown as SaMatchDetail
} catch {
return null
}
}
return { getMatches, getMatchDetail }
}
+30
View File
@@ -0,0 +1,30 @@
import { useApi } from './useApi'
// 메타 항목 (이름 → 이미지)
export interface SaMetaItem {
name: string
image: string
}
// 종류별 메타 { grade, season_grade, tier }
export type SaMetaSections = Record<string, SaMetaItem[]>
export function useSaMeta() {
const api = useApi()
async function getMeta(): Promise<SaMetaSections> {
try {
return (await api.get('/sa-meta')) as unknown as SaMetaSections
} catch {
return {}
}
}
// 종류 + 이름으로 이미지 URL 조회 (없으면 빈 문자열)
function imageOf(meta: SaMetaSections, type: string, name: string): string {
const list = meta[type] ?? []
return list.find((m) => m.name === name)?.image ?? ''
}
return { getMeta, imageOf }
}
+68
View File
@@ -0,0 +1,68 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// 유저 OUID 검색 결과
export interface SaOuid {
ouid: string
userName: string
}
// 유저 종합 프로필 (백엔드 ProfileResponseDto)
export interface SaProfile {
ouid: string
userName: string
dateCreate: string
titleName: string
clanName: string
mannerGrade: string
grade: string
gradeExp: number
gradeRanking: number
seasonGrade: string
seasonGradeExp: number
seasonGradeRanking: number
soloTier: string
soloScore: number
partyTier: string
partyScore: number
recentWinRate: number
recentKdRate: number
recentAssaultRate: number
recentSniperRate: number
recentSpecialRate: number
lastSyncedAt: string
}
export function useSaUser() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
// 닉네임 → OUID 검색 (실패 시 null, 인터셉터가 에러 알림 처리)
async function searchOuid(userName: string): Promise<SaOuid | null> {
loading.value = true
error.value = null
try {
return (await api.get('/sa-users/search', {
params: { user_name: userName },
})) as unknown as SaOuid
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return null
} finally {
loading.value = false
}
}
// OUID → 종합 프로필
async function getProfile(ouid: string): Promise<SaProfile | null> {
try {
return (await api.get(`/sa-users/${ouid}/profile`)) as unknown as SaProfile
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return null
}
}
return { loading, error, searchOuid, getProfile }
}
+50 -3
View File
@@ -2,8 +2,9 @@
// 클랜 홈 — 배너 + 스탯 스트립 + 소개 + 2단 섹션. sa-clan.jsx ClanHome 포팅.
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { ClanDB } from '@/shared/data/clan'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { SA } from '@/shared/mock/sa'
import { ClanDB } from '@/shared/mock/clan'
import AppIcon from '@/shared/components/AppIcon.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
@@ -17,6 +18,8 @@ import ClanMembers from '@/pages/clan/ClanMembers.vue'
const route = useRoute()
const router = useRouter()
// 반응형 분기 — 모바일(<760)에서 히어로 엠블럼 size 축소(원본 sa-clan.jsx)
const { mobile } = useBreakpoint()
const clan = computed(() => ClanDB(route.params.name as string))
const stats = computed(() => [
@@ -59,7 +62,7 @@ function back() {
<div class="clan__emblem">
<ClanEmblem
:cfg="clan.emblem"
:size="104"
:size="mobile ? 64 : 104"
/>
</div>
<div class="clan__title">
@@ -334,4 +337,48 @@ function back() {
gap: 18px;
min-width: 0;
}
/* ─────────────────────────────────────────────
반응형 (데스크톱 레이아웃 유지, 좁은 화면만 재배치)
원본 searchgg 메인 홈.html @media 및 sa-clan.jsx mobile 분기 대응
───────────────────────────────────────────── */
/* 모바일(<760): 히어로 제목/슬로건/버튼 축소 — 엠블럼 size는 JS(mobile)로 분기 */
@media (max-width: 760px) {
/* 좌우 패딩 축소 (원본 mobile: '0 16px 14px') */
.clan__identity {
padding: 0 1rem 0.875rem;
gap: 0.75rem;
}
/* 제목 30 → 21px */
.clan__name {
font-size: 1.3125rem;
}
/* 슬로건 14.5 → 12.5px */
.clan__slogan {
font-size: 0.78125rem;
}
/* 가입 버튼 padding/fontSize 축소 (원본 '8px 14px' / 12px) */
.clan__join {
padding: 0.5rem 0.875rem;
font-size: 0.75rem;
}
}
/* 태블릿(<960): 2단 본문 그리드 → 1단 (원본 sg-grid-main) */
@media (max-width: 960px) {
.clan__grid {
grid-template-columns: 1fr;
}
}
/* 모바일(<600): wrap 좌우 패딩 16px(1rem) 축소 (원본 sg-wrap) */
@media (max-width: 600px) {
.clan__topbar,
.clan__statstrip-inner,
.clan__body {
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
+21 -2
View File
@@ -2,8 +2,8 @@
// 클랜 모집 광장 — 히어로 + 필터 + 카드 그리드 + 상세/등록 모달. sa-clan-plaza.jsx ClanPlaza 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { buildRecruits, RECRUIT_TYPES, type Recruit } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import { buildRecruits, RECRUIT_TYPES, type Recruit } from '@/shared/mock/clan'
import AppIcon from '@/shared/components/AppIcon.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import RecruitCard from '@/pages/clan/RecruitCard.vue'
@@ -692,4 +692,23 @@ function goMyClan() {
background: var(--point);
color: var(--point-ink);
}
/* 반응형 — 카드 그리드(sg-plaza-cards 대응) 760px 이하 1열 */
@media (max-width: 760px) {
.plaza__grid {
grid-template-columns: 1fr;
}
}
/* 반응형 — 600px 이하 페이지 좌우 패딩 축소(28px → 16px=1rem) */
@media (max-width: 600px) {
.plaza__hero-inner,
.plaza__filterbar-inner,
.plaza__detailrow-inner,
.plaza__mywrap,
.plaza__grid-wrap {
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
+21 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 커뮤니티 — 게시판 목록 + 검색/필터 + 상세/글쓰기 + 사이드. sa-community.jsx CommunityScreen 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import {
CM_BOARDS,
CM_NOTICES,
@@ -9,7 +9,7 @@ import {
CM_POSTS,
cmBody,
type CmPost,
} from '@/shared/data/community'
} from '@/shared/mock/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmRow from '@/pages/community/CmRow.vue'
import CmSidePanel from '@/pages/community/CmSidePanel.vue'
@@ -380,6 +380,10 @@ function hotView(p: CmPost) {
gap: 18px;
align-items: start;
}
/* 그리드 자식이 좁아질 때 내용이 넘치지 않도록 (sg-grid-main > * { min-width: 0 }) */
.cm__grid > * {
min-width: 0;
}
.cm__board {
background: var(--surf);
border: 1px solid var(--line);
@@ -559,4 +563,19 @@ function hotView(p: CmPost) {
flex-direction: column;
gap: 18px;
}
/* ===== 반응형 (원본 sg-grid-main / sg-wrap @media 대응) ===== */
/* 태블릿 이하: 메인 2단 그리드 → 1단 (sg-grid-main: 1fr) */
@media (max-width: 960px) {
.cm__grid {
grid-template-columns: 1fr;
}
}
/* 모바일: 페이지 좌우 패딩 축소 16px(1rem) (sg-wrap) */
@media (max-width: 600px) {
.cm__inner {
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
+22 -2
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
// 홈 페이지 — 히어로 + 이벤트 + 좌(콘텐츠)/우(사이드) 2단 그리드. sa-home.jsx Home 포팅.
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user.store'
import HomeHero from '@/pages/home/HomeHero.vue'
import EventStrip from '@/pages/home/EventStrip.vue'
import StreamLive from '@/pages/home/StreamLive.vue'
@@ -13,6 +15,10 @@ import SeasonCard from '@/pages/home/SeasonCard.vue'
import SaRanking from '@/pages/home/SaRanking.vue'
import SearchRank from '@/pages/home/SearchRank.vue'
import LiveStats from '@/pages/home/LiveStats.vue'
// 내 전적/즐겨찾기 카드는 로그인 상태에서만 노출
const userStore = useUserStore()
const { isLoggedIn } = storeToRefs(userStore)
</script>
<template>
@@ -29,8 +35,8 @@ import LiveStats from '@/pages/home/LiveStats.vue'
<CommunityPreview />
</div>
<div class="home__col">
<MeCard />
<FavoritesPanel />
<MeCard v-if="isLoggedIn" />
<FavoritesPanel v-if="isLoggedIn" />
<SeasonCard />
<SaRanking />
<SearchRank />
@@ -62,4 +68,18 @@ import LiveStats from '@/pages/home/LiveStats.vue'
gap: 18px;
min-width: 0;
}
/* 반응형 — 태블릿 이하: 우측 사이드 컬럼을 본문 아래로 1단 배치 */
@media (max-width: 960px) {
.home__grid {
grid-template-columns: 1fr;
}
}
/* 반응형 — 모바일: 좌우 패딩 축소(16px=1rem), 세로 패딩 유지 */
@media (max-width: 600px) {
.home__inner {
padding: 1.25rem 1rem 2.5rem;
}
}
</style>
+271 -47
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
import { computed, ref } from 'vue'
// UI 구조는 기존(mock 디자인) 그대로 유지하고, 데이터만 넥슨 실데이터를 어댑터로 변환해 주입한다.
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SA, findPlayer } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { useSaUser, type SaProfile } from '@/composables/useSaUser'
import { useSaMatch, MATCH_TYPES, type SaMatchItem } from '@/composables/useSaMatch'
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
import { makeRank } from '@/shared/utils/adapt'
import type { Player, MatchRecord, RankedRecord, ClanRecord } 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'
@@ -16,13 +20,198 @@ import ModeRecords from '@/pages/profile/ModeRecords.vue'
import MapRecords from '@/pages/profile/MapRecords.vue'
const route = useRoute()
// 라우트 파라미터로 유저 조회. 없으면 로그인 유저(나) 폴백.
const p = computed<Player>(() => {
const name = route.params.name as string
return findPlayer(name) ?? SA.me
})
const { searchOuid, getProfile } = useSaUser()
const { getMatches } = useSaMatch()
const { getMeta, imageOf } = useSaMeta()
const loading = ref(false)
const notFound = ref(false)
const p = ref<Player | null>(null)
const tab = ref('전적')
// 넥슨 매치타입 → 기존 UI 탭 라벨
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))
return r / (100 - r)
}
// 랭크전 매치(솔로/파티)를 집계해 시즌 전적(RankedRecord) 생성.
// 티어/점수는 프로필 tier API 값, 전적/승률/킬뎃은 매치 목록 집계.
function aggRanked(
items: SaMatchItem[],
type: string,
tier: string,
points: number,
tierImage: string,
): RankedRecord {
const f = items.filter((m) => m.matchType === type)
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
})
return {
matches,
w,
l,
wr: matches ? Math.round((w / matches) * 100) : 0,
kd: matches ? Math.round(kdSum / matches) : 0,
hs: 0,
tier: tier || 'UNRANK',
div: null,
points,
tierImage,
}
}
// 클랜전 전적 — 클랜이 있으면 클랜전(퀵매치 클랜전 + 클랜 랭크전) 매치를 집계.
// 전적이 없으면 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
})
// 현재 가입된 클랜이므로 가입 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),
matches,
w,
l,
wr: matches ? Math.round((w / matches) * 100) : 0,
kd: matches ? Math.round(kdSum / matches) : 0,
periods: [{ from: '????', to: nowYm, months: 0 }],
},
]
}
function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
return items.map((m) => ({
matchId: m.matchId,
mode: TYPE_LABEL[m.matchType] ?? m.matchType,
win: m.matchResult === '1',
map: '',
k: m.kill,
d: m.death,
a: m.assist,
ago: fmtAgo(m.dateMatch),
winSide: 'blue',
teams: { blue: [], red: [] },
}))
}
// 넥슨 프로필/매치 → 기존 UI가 기대하는 mock Player 형태로 변환.
// 넥슨이 제공하지 않는 항목(집계/추세/함께한 유저 등)은 빈 값으로 두고 2차에서 채운다.
function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections): Player {
const gradeImg = imageOf(meta, 'grade', pr.grade)
const seasonImg = imageOf(meta, 'season_grade', pr.seasonGrade)
return {
name: pr.userName,
clan: pr.clanName || null,
rank: makeRank(pr.grade, gradeImg),
rankTotal: makeRank(pr.grade, gradeImg),
rankSeason: makeRank(pr.seasonGrade, seasonImg),
rankNoTotal: pr.gradeRanking,
rankNoSeason: pr.seasonGradeRanking,
level: 0,
wins: 0,
losses: 0,
wr: Math.round(pr.recentWinRate * 10) / 10,
kda: kdaFromPct(pr.recentKdRate),
hs: 0,
rifle: Math.round(pr.recentAssaultRate),
sniper: Math.round(pr.recentSniperRate),
special: Math.round(pr.recentSpecialRate),
manner: 0,
mannerGrade: pr.mannerGrade,
connRate: 0,
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: [] },
together: { games: 0, w: 0, l: 0 },
}
}
async function load() {
const name = (route.params.name as string)?.trim()
if (!name) return
loading.value = true
notFound.value = false
p.value = null
const res = await searchOuid(name)
if (!res) {
notFound.value = true
loading.value = false
return
}
const results = await Promise.all([
getProfile(res.ouid),
getMeta(),
...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[][]
if (!profile) {
notFound.value = true
loading.value = false
return
}
const allMatches = matchLists
.flat()
.sort((a, b) => b.dateMatch.localeCompare(a.dateMatch))
p.value = adaptPlayer(profile, allMatches, meta)
loading.value = false
}
watch(() => route.params.name, load, { immediate: true })
</script>
<template>
@@ -37,49 +226,64 @@ const tab = ref('전적')
</div>
<div class="pf__body">
<ProfileSummary :p="p" />
<div
v-if="loading"
class="pf__state"
>
불러오는 ...
</div>
<div
v-else-if="notFound"
class="pf__state"
>
요청하신 유저를 찾을 없습니다.
</div>
<!-- 서브탭 -->
<div class="pf__tabs">
<button
v-for="t in ['전적', '통계']"
:key="t"
class="pf__tab"
:class="{ 'pf__tab--on': tab === t }"
@click="tab = t"
<template v-else-if="p">
<ProfileSummary :p="p" />
<!-- 서브탭 -->
<div class="pf__tabs">
<button
v-for="t in ['전적', '통계']"
:key="t"
class="pf__tab"
:class="{ 'pf__tab--on': tab === t }"
@click="tab = t"
>
{{ t }}
<span
v-if="tab === t"
class="pf__tab-line"
:style="{ background: p.rank.color }"
/>
</button>
</div>
<div
v-if="tab === '전적'"
class="pf__grid"
>
{{ t }}
<span
v-if="tab === t"
class="pf__tab-line"
:style="{ background: p.rank.color }"
/>
</button>
</div>
<div
v-if="tab === '전적'"
class="pf__grid"
>
<div class="pf__col">
<RankedSeasons :p="p" />
<ClanRecords :p="p" />
<MatchHistory :p="p" />
<div class="pf__col">
<RankedSeasons :p="p" />
<ClanRecords :p="p" />
<MatchHistory :p="p" />
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<Companions :p="p" />
</div>
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<Companions :p="p" />
</div>
</div>
<div
v-else
class="pf__stack"
>
<TrendChart :p="p" />
<ModeRecords :p="p" />
<MapRecords :p="p" />
</div>
<div
v-else
class="pf__stack"
>
<TrendChart :p="p" />
<ModeRecords :p="p" />
<MapRecords :p="p" />
</div>
</template>
</div>
</div>
</template>
@@ -107,6 +311,12 @@ const tab = ref('전적')
margin: 0 auto;
padding: 24px 28px 44px;
}
.pf__state {
padding: 60px 0;
text-align: center;
color: var(--t3);
font-size: 0.875rem;
}
.pf__tabs {
display: flex;
gap: 4px;
@@ -152,4 +362,18 @@ const tab = ref('전적')
flex-direction: column;
gap: 18px;
}
/* ── 반응형 ── */
@media (max-width: 960px) {
.pf__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.pf__subhead-inner,
.pf__body {
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 주간 활동 — 요일별 접속률 막대. sa-clan-sections.jsx ClanActivity 포팅.
import { computed } from 'vue'
import type { Clan } from '@/shared/data/clan'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 클랜 피드 — 작성(내 클랜) + 페이지네이션. sa-clan-sections.jsx ClanFeed 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanFeedItem } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan, ClanFeedItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ClanPager from './ClanPager.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 방명록 — 방문자 글 + 클랜원 답글. sa-clan-sections.jsx ClanGuestbook 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanGuestItem } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan, ClanGuestItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 클랜원 — 역할별 그룹 + 기여도. sa-clan-sections.jsx ClanMembers 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Clan } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜 공지 — 페이지네이션. sa-clan-sections.jsx ClanNotices 포팅.
import { computed, ref } from 'vue'
import type { Clan } from '@/shared/data/clan'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜 일정 — 주간 정기전 + 등록/삭제(내 클랜). sa-clan-sections.jsx ClanSchedule 포팅.
import { computed, ref, watch } from 'vue'
import type { Clan, ClanScheduleItem } from '@/shared/data/clan'
import type { Clan, ClanScheduleItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 주간 매치 — 요일별 모드 스택 막대 + 합계. sa-clan-sections.jsx ClanWeeklyMatches 포팅.
import { computed } from 'vue'
import type { Clan, ClanActivityDay } from '@/shared/data/clan'
import type { Clan, ClanActivityDay } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 모집 카드 — 포스터 + 클랜 정보 + 모집 현황 막대. sa-clan-plaza.jsx RecruitCard 포팅.
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import { recruitAgeLabel, type Recruit } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 모집글 등록 모달 — 포스터/유형/조건/안내/문의채널. sa-clan-plaza.jsx RecruitCreate 포팅(데모).
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { ClanDB, RECRUIT_TYPES } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import { ClanDB, RECRUIT_TYPES } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
+8 -1
View File
@@ -2,7 +2,7 @@
// 모집 상세 모달 — 포스터 + 통계 + 가입 조건 + 액션. sa-clan-plaza.jsx RecruitDetail 포팅.
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import { recruitAgeLabel, type Recruit } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
@@ -364,4 +364,11 @@ function openClan() {
.rd__contact--kakao {
background: var(--kakao);
}
/* 반응형 — 가입 조건 그리드(sg-cond2 대응) 760px 이하 1열 */
@media (max-width: 760px) {
.rd__cond {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 작성자 + 서든어택 인증 마크. sa-community.jsx CmAuthor/CmVerifiedTag 포팅.
import { computed } from 'vue'
import { cmVerified } from '@/shared/data/community'
import { cmVerified } from '@/shared/mock/community'
interface Props {
name: string
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 커뮤니티 글 상세 — 본문 + 추천/비추 + 댓글/답글. sa-community.jsx CmPostDetail 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmBody, cmComments, cmPartyBadges, type CmComment, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BEST_UP, cmBody, cmComments, cmPartyBadges, type CmComment, type CmPost } from '@/shared/mock/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmAuthor from './CmAuthor.vue'
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 커뮤니티 목록 행. sa-community.jsx CmRow 포팅.
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmPartyBadges, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BEST_UP, cmPartyBadges, type CmPost } from '@/shared/mock/community'
import CmAuthor from './CmAuthor.vue'
interface Props {
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// / . sa-community.jsx CmSidePanel .
import type { CmPost } from '@/shared/data/community'
import type { CmPost } from '@/shared/mock/community'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// / . sa-community.jsx CmWrite .
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BOARDS, CM_PARTY_TYPES, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BOARDS, CM_PARTY_TYPES, type CmPost } from '@/shared/mock/community'
interface Props {
initialBoard: string
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// + 6. sa-home.jsx Community .
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
+15 -1
View File
@@ -2,7 +2,7 @@
// 4 + . sa-home.jsx EventStrip .
// : (/api/events/ongoing). (SA.events) .
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { useEvents } from '@/composables/useEvents'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -318,4 +318,18 @@ onBeforeUnmount(stopTimer)
width: 20px;
background: var(--point);
}
/* 반응형 — 태블릿 이하: 한 페이지에 2개씩 */
@media (max-width: 960px) {
.es__page {
grid-template-columns: repeat(2, 1fr);
}
}
/* 반응형 — 모바일: 한 페이지에 1개씩 */
@media (max-width: 440px) {
.es__page {
grid-template-columns: 1fr;
}
}
</style>
+2 -2
View File
@@ -2,8 +2,8 @@
// . sa-home.jsx Favorites .
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// + +
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import SearchBar from '@/shared/components/SearchBar.vue'
import RecentChips from '@/shared/components/RecentChips.vue'
+9 -2
View File
@@ -2,9 +2,9 @@
// // . sa-home.jsx HotStreak .
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
@@ -189,4 +189,11 @@ function open(p: Player) {
.hs__pct--pt {
color: var(--point);
}
/* 반응형 — 좁은 화면: 카드 1열로 전환 */
@media (max-width: 760px) {
.hs__grid {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// //. sa-home.jsx LiveStats .
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const L = SA.liveStats
+1 -1
View File
@@ -2,7 +2,7 @@
// / + . sa-home.jsx MeCard .
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { wrColor } from '@/shared/utils/stats'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
+8 -1
View File
@@ -2,7 +2,7 @@
// 4. sa-home.jsx RecruitPosters .
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { buildRecruits } from '@/shared/data/clan'
import { buildRecruits } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -117,4 +117,11 @@ function goClan(name: string) {
color: var(--t3);
padding-left: 27px;
}
/* 반응형 — 모바일: 포스터 2열로 축소 */
@media (max-width: 600px) {
.rp__grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+23 -3
View File
@@ -1,12 +1,31 @@
<script setup lang="ts">
// SA /GM/ . sa-home.jsx SANews .
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
// : (/api/news/sections). (SA.news) .
import { computed, onMounted, ref } from 'vue'
import { SA } from '@/shared/mock/sa'
import { useNews, type NewsItem } from '@/composables/useNews'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
const tab = ref('공지사항')
const rows = computed(() => SA.news[tab.value] ?? [])
//
const sections = ref<Record<string, Partial<NewsItem>[]>>(SA.news)
// 5
const rows = computed(() => (sections.value[tab.value] ?? []).slice(0, 5))
// (). , / .
const { getSections } = useNews()
onMounted(async () => {
const data = await getSections()
//
if (Object.values(data).some((list) => list.length)) {
sections.value = data
}
})
function openNews(link?: string) {
if (link) window.open(link, '_blank', 'noopener')
}
</script>
<template>
@@ -27,6 +46,7 @@ const rows = computed(() => SA.news[tab.value] ?? [])
:key="i"
class="news__row"
:class="{ 'news__row--line': i < rows.length - 1 }"
@click="openNews(r.link)"
>
<span class="news__date num">{{ r.date }}</span>
<span class="news__title">{{ r.title }}</span>
+177 -110
View File
@@ -1,37 +1,48 @@
<script setup lang="ts">
// SA / , (//), / . sa-home.jsx SARanking .
import { computed, ref } from 'vue'
// SA / , (//), / .
// : (/api/ranking/sections). = , / .
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { useRanking, type RankingItem } from '@/composables/useRanking'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜']
const cat = ref('통합 계급')
const mode = ref('솔로')
const tcol = computed(() => Object.fromEntries(SA.tiers.map((t) => [t.name, t.color])))
const isClass = computed(() => cat.value === '시즌 계급' || cat.value === '통합 계급')
const sections = ref<Record<string, RankingItem[]>>({})
const isClass = computed(() => cat.value === '통합 계급' || cat.value === '시즌 계급')
const isRanked = computed(() => cat.value === '랭크전')
const isRankedUser = computed(() => isRanked.value && mode.value !== '클랜')
const isRankedClan = computed(() => isRanked.value && mode.value === '클랜')
const isClan = computed(() => cat.value === '공식 클랜' || cat.value === '클랜')
const isOfficialClan = computed(() => cat.value === '공식 클랜')
const isClan = computed(() => cat.value === '클랜')
const classList = computed(() =>
cat.value === '시즌 계급' ? SA.seasonClassRanking : SA.totalClassRanking,
)
const contentList = computed(() => (mode.value === '솔로' ? SA.soloRpRanking : SA.partyRpRanking))
const clanList = computed(() =>
cat.value === '공식 클랜' ? SA.officialClanRanking : SA.generalClanRanking,
)
// /
const catKey = computed(() => {
if (cat.value === '통합 계급') return 'total_class'
if (cat.value === '시즌 계급') return 'season_class'
if (cat.value === '공식 클랜') return 'official_clan'
if (cat.value === '클랜') return 'clan'
//
if (mode.value === '솔로') return 'ranked_solo'
if (mode.value === '파티') return 'ranked_party'
return 'ranked_clan'
})
const rows = computed(() => (sections.value[catKey.value] ?? []).slice(0, 10))
const { getSections } = useRanking()
onMounted(async () => {
const data = await getSections()
if (Object.values(data).some((list) => list.length)) {
sections.value = data
}
})
function findP(name: string) {
return SA.allPlayers.find((x) => x.name === name)
}
function openPlayer(name: string) {
const p = findP(name)
if (p) router.push({ name: 'profile', params: { name: p.name } })
router.push({ name: 'profile', params: { name } })
}
function openClan(name: string) {
router.push({ name: 'clan', params: { name } })
@@ -79,11 +90,19 @@ function openClan(name: string) {
</div>
</div>
<!-- 계급 랭킹 -->
<!-- 상태 -->
<div
v-if="!rows.length"
class="sr__empty"
>
랭킹을 집계 중입니다.
</div>
<!-- 계급 랭킹 (통합/시즌) 주지표: 승률 -->
<template v-if="isClass">
<button
v-for="(e, i) in classList"
:key="e.name"
v-for="(e, i) in rows"
:key="e.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
@@ -91,32 +110,30 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<RankBadge
:rank="findP(e.name)?.rank ?? null"
:size="28"
/>
>{{ e.rankNo }}</span>
<img
v-if="e.gradeImg"
:src="e.gradeImg"
class="sr__img"
alt="계급"
>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
<div class="sr__name">
{{ e.name }}
</div>
<div class="sr__sub">
{{ findP(e.name)?.rank.name }}
킬뎃 {{ e.kd }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.score) }}<span class="sr__unit"></span></span>
<span class="sr__val num">{{ e.winRate }}</span>
</button>
</template>
<!-- 랭크전(솔로/파티) -->
<!-- 랭크전 (솔로/파티) 주지표: RP, 티어 이미지 -->
<template v-if="isRankedUser">
<button
v-for="(e, i) in contentList"
:key="e.name"
v-for="(e, i) in rows"
:key="e.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
@@ -124,37 +141,30 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div
class="sr__tier"
:style="{ background: tcol[e.tier] || 'var(--point)' }"
>{{ e.rankNo }}</span>
<img
v-if="e.tierImg"
:src="e.tierImg"
class="sr__img"
alt="티어"
>
{{ e.tier[0] }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
<div class="sr__name">
{{ e.name }}
</div>
<div
class="sr__sub"
:style="{ color: tcol[e.tier], fontWeight: 600 }"
>
{{ e.tier }}
<div class="sr__sub">
승률 {{ e.winRate }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.rp) }}<span class="sr__unit">RP</span></span>
<span class="sr__val num">{{ e.rp }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 랭크전(클랜) -->
<!-- 랭크전 (클랜) 주지표: 클랜 RP -->
<template v-if="isRankedClan">
<button
v-for="(c, i) in SA.clanRpRanking"
:key="c.name"
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
@@ -162,27 +172,41 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
{{ c.members }}
승률 {{ c.winRate }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
<span class="sr__val num">{{ c.rp }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 클랜 랭킹 -->
<template v-if="isClan">
<!-- 공식 클랜 주지표: 누적점수 -->
<template v-if="isOfficialClan">
<button
v-for="(c, i) in clanList"
:key="c.name"
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
@@ -190,23 +214,74 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ c.name }}</span>
<span
v-if="cat === '공식 클랜'"
class="sr__cert"
>인증</span>
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
{{ c.members }}
마스터 {{ c.master }}
</div>
</div>
<span class="sr__val num">{{ c.score }}<span class="sr__unit"></span></span>
</button>
</template>
<!-- 클랜 (일반) 점수 없음, 마스터 표시 -->
<template v-if="isClan">
<button
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
마스터 {{ c.master }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
</button>
</template>
</UiPanel>
@@ -268,6 +343,12 @@ function openClan(name: string) {
background: var(--point);
color: var(--point-ink);
}
.sr__empty {
padding: 32px 14px;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
.sr__row {
display: flex;
align-items: center;
@@ -295,17 +376,26 @@ function openClan(name: string) {
.sr__no--top {
color: var(--point);
}
.sr__tier {
.sr__img {
width: 28px;
height: 28px;
border-radius: var(--r-xs);
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 0.78125rem;
object-fit: contain;
flex-shrink: 0;
}
/* 클랜마크 — 배경(0_)+심볼(1_) 2개 이미지를 겹쳐서 표시 */
.sr__mark {
position: relative;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.sr__mark img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.sr__clantag {
width: 28px;
height: 28px;
@@ -322,11 +412,6 @@ function openClan(name: string) {
flex: 1;
min-width: 0;
}
.sr__name-line {
display: flex;
align-items: center;
gap: 5px;
}
.sr__name {
font-size: 0.84375rem;
color: var(--ink);
@@ -334,25 +419,7 @@ function openClan(name: string) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.sr__tag {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr__cert {
font-size: 0.5625rem;
font-weight: 700;
color: var(--point);
border: 1px solid var(--point);
border-radius: 3px;
padding: 0 4px;
line-height: 1.5;
max-width: 100%;
}
.sr__sub {
font-size: 0.65625rem;
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 24 + (/NEW). sa-home.jsx SearchRank .
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const router = useRouter()
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// + (5v5/3v3). sa-home.jsx SeasonCard .
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const s = SA.season
+9 -2
View File
@@ -2,8 +2,8 @@
// LIVE // . + . sa-home.jsx StreamLive .
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { StreamPlatform } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { StreamPlatform } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -220,4 +220,11 @@ function openStreamer(name: string) {
color: var(--t3);
flex-shrink: 0;
}
/* 반응형 — 좁은 화면: 방송 카드 1열로 전환 */
@media (max-width: 760px) {
.sl__grid {
grid-template-columns: 1fr;
}
}
</style>
+7 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// , . sa-profile-sections.jsx ClanRecords .
import { computed, ref } from 'vue'
import type { ClanRecord, Player } from '@/shared/data/types'
import type { ClanRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import WrCell from '@/shared/components/WrCell.vue'
@@ -75,7 +75,9 @@ function recStr(d: ClanRecord) {
stroke="var(--t3)"
:style="{ transform: open === i ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
/>
<!-- 클랜 태그/이미지 넥슨 미제공으로 숨김
<span class="cr__tag num">{{ c.tag }}</span>
-->
<span class="cr__name">{{ c.clan }}</span>
<span
v-if="i === 0"
@@ -121,7 +123,10 @@ function recStr(d: ClanRecord) {
stroke="var(--t3)"
/>
<span class="cr__period-range num">{{ pd.from }} ~ {{ pd.to }}</span>
<span class="cr__period-months"> {{ pd.months }}개월</span>
<span
v-if="pd.months > 0"
class="cr__period-months"
> {{ pd.months }}개월</span>
</div>
</div>
</td>
+3 -7
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
// /. sa-profile-sections.jsx Companions .
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
@@ -47,10 +46,7 @@ function open(f: Player) {
함께 {{ f.together.games }}<span class="cp__mid">·</span>
<b class="num cp__w">{{ f.together.w }}</b> <b class="num cp__l">{{ f.together.l }}</b>
<span class="cp__mid">·</span>
<span
class="num cp__wr"
:style="{ color: wrColor(togetherWr(f)) }"
>승률 {{ togetherWr(f) }}%</span>
<span class="num cp__wr">승률 {{ togetherWr(f) }}%</span>
</div>
</div>
<AppIcon
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// . sa-profile-sections.jsx MapRecords .
import { computed, ref } from 'vue'
import type { MapRecord, Player } from '@/shared/data/types'
import type { MapRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// + . sa-profile-sections.jsx MatchHistory .
import { computed, ref, watch } from 'vue'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import MatchRow from './MatchRow.vue'
+74 -19
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
// 1 / + / + KDA, . sa-profile-sections.jsx MatchRow .
// (/) () match-detail API lazy .
import { computed, ref } from 'vue'
import type { MatchRecord } from '@/shared/data/types'
import { useSaMatch, type SaParticipant } from '@/composables/useSaMatch'
import { makeRank } from '@/shared/utils/adapt'
import type { MatchRecord, RosterPlayer } from '@/shared/mock/types'
import KdaChip from '@/shared/components/KdaChip.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import TeamTable from './TeamTable.vue'
@@ -12,10 +15,47 @@ interface Props {
}
const props = defineProps<Props>()
const { getMatchDetail } = useSaMatch()
const open = ref(false)
const pct = computed(() => Math.round((props.m.k / (props.m.k + props.m.d)) * 100))
const winTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.blue : props.m.teams.red))
const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red : props.m.teams.blue))
const loading = ref(false)
const loaded = ref(false)
const mapName = ref(props.m.map)
const winTeam = ref<RosterPlayer[]>([])
const loseTeam = ref<RosterPlayer[]>([])
const pct = computed(() => {
const total = props.m.k + props.m.d
return total ? Math.round((props.m.k / total) * 100) : 0
})
// UI (RosterPlayer)
function toRoster(p: SaParticipant): RosterPlayer {
return {
rank: makeRank(p.seasonGrade),
name: p.userName,
isOwner: false,
k: p.kill,
d: p.death,
a: p.assist,
hs: p.headshot,
dmg: p.damage,
}
}
async function toggle() {
open.value = !open.value
// 1 (matchId )
if (!open.value || loaded.value || !props.m.matchId) return
loading.value = true
const detail = await getMatchDetail(props.m.matchId)
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)
}
loaded.value = true
loading.value = false
}
</script>
<template>
@@ -27,7 +67,7 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
class="mrow__head"
:class="{ 'mrow__head--open': open }"
:style="{ borderLeftColor: m.win ? 'var(--point)' : 'var(--negative)' }"
@click="open = !open"
@click="toggle"
>
<div class="mrow__result">
<span
@@ -37,7 +77,7 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
</div>
<div class="mrow__map">
<div class="mrow__mapname">
{{ m.map }}
{{ mapName || '—' }}
</div>
<div class="mrow__mode">
{{ m.mode }}
@@ -65,20 +105,28 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
v-if="open"
class="mrow__detail"
>
<div class="mrow__team">
<TeamTable
result="승리"
color="var(--point)"
:players="winTeam"
/>
</div>
<div class="mrow__team">
<TeamTable
result="패배"
color="var(--negative)"
:players="loseTeam"
/>
<div
v-if="loading"
class="mrow__loading"
>
상세 불러오는 ...
</div>
<template v-else>
<div class="mrow__team">
<TeamTable
result="승리"
color="var(--point)"
:players="winTeam"
/>
</div>
<div class="mrow__team">
<TeamTable
result="패배"
color="var(--negative)"
:players="loseTeam"
/>
</div>
</template>
</div>
</div>
</template>
@@ -161,4 +209,11 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
overflow: hidden;
background: var(--surf);
}
.mrow__loading {
grid-column: 1 / -1;
text-align: center;
padding: 14px 0;
font-size: 0.78125rem;
color: var(--t3);
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// . sa-profile-sections.jsx ModeRecords .
import type { ModeRecord, Player } from '@/shared/data/types'
import type { ModeRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
@@ -1,9 +1,8 @@
<script setup lang="ts">
// // + · + 9 . sa-profile-sections.jsx ProfileSummary .
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import WinRing from '@/shared/components/WinRing.vue'
@@ -19,14 +18,14 @@ const selRank = computed(() => (scope.value === '통합' ? props.p.rankTotal : p
const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal : props.p.rankNoSeason))
const stats = computed(() => [
{ lab: '승률', val: props.p.wr + '%', col: wrColor(props.p.wr) },
{ lab: '승률', val: props.p.wr + '%', col: 'var(--ink)' },
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
{ lab: '헤드샷', val: props.p.hs + '%', col: 'var(--ink)' },
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
{ lab: '스나', val: props.p.sniper + '%', col: 'var(--ink)' },
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
{ lab: '랭킹', val: SA.fmt(selRankNo.value) + '위', col: 'var(--point)' },
{ lab: '매너', val: props.p.manner + '', col: 'var(--ink)' },
{ lab: '매너', val: props.p.mannerGrade ?? '-', col: 'var(--ink)' },
{ lab: '접속률', val: props.p.connRate + '%', col: 'var(--ink)' },
])
</script>
+4 -3
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// ( ) · /. sa-profile-sections.jsx RankedSeasons .
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Player, RankedRecord } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player, RankedRecord } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
import TierChip from '@/shared/components/TierChip.vue'
@@ -120,6 +120,7 @@ function recStr(d: RankedRecord) {
v-else
:name="d.tier"
:div="d.div"
:image="d.tierImage"
/>
</td>
<td class="ta-r td-num">
@@ -127,7 +128,7 @@ function recStr(d: RankedRecord) {
v-if="isAll"
class="rs__dash"
></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> </span></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> RP</span></span>
</td>
<td class="ta-r td-num">
{{ d.matches }}
+1 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// / + . sa-profile-sections.jsx RecentTrend .
import { computed } from 'vue'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WinRing from '@/shared/components/WinRing.vue'
@@ -56,9 +56,6 @@ const flow = computed(() => recent.value.slice().reverse())
{{ m.win ? '승' : '패' }}
</span>
</div>
<div class="rt__caption">
최신 경기 흐름
</div>
</UiPanel>
</template>
@@ -102,9 +99,4 @@ const flow = computed(() => recent.value.slice().reverse())
.rt__cell--l {
background: rgba(229, 72, 77, 0.9);
}
.rt__caption {
font-size: 0.65625rem;
color: var(--t3);
margin-top: 7px;
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// / . sa-profile-sections.jsx TeamTable .
import { SA } from '@/shared/data/sa'
import type { RosterPlayer } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { RosterPlayer } from '@/shared/mock/types'
import RankBadge from '@/shared/components/RankBadge.vue'
interface Props {
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// / (/). sa-profile-sections.jsx TrendChart .
import { computed, ref } from 'vue'
import type { Player, TrendPoint } from '@/shared/data/types'
import type { Player, TrendPoint } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
// (///) + . sa-clan-data.jsx Emblem .
import { computed } from 'vue'
import { EMBLEM_SHAPES, shade, type ClanEmblem } from '@/shared/data/clan'
import { EMBLEM_SHAPES, shade, type ClanEmblem } from '@/shared/mock/clan'
interface Props {
cfg?: ClanEmblem
+15 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// () + + pip( ). sa-shared.jsx RankBadge .
import { computed } from 'vue'
import type { Rank } from '@/shared/data/types'
import type { Rank } from '@/shared/mock/types'
interface Props {
rank: Rank | null
@@ -45,7 +45,17 @@ const pips = computed<Pip[]>(() => {
class="rb"
:style="{ gap: showName ? '8px' : '0' }"
>
<!-- 넥슨 메타 계급 이미지가 있으면 이미지로 표시 -->
<img
v-if="rank.image"
:src="rank.image"
:width="s"
:height="s"
:alt="rank.name"
class="rb__img"
>
<svg
v-else
:width="s"
:height="s"
:viewBox="`0 0 ${s} ${s}`"
@@ -140,6 +150,10 @@ const pips = computed<Pip[]>(() => {
display: flex;
align-items: center;
}
.rb__img {
object-fit: contain;
flex-shrink: 0;
}
.rb-name {
line-height: 1.1;
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
// . sa-shared.jsx RecentChips .
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import AppIcon from './AppIcon.vue'
interface Props {
+36 -172
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
// (/). .
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
// OUID .
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { useSaUser } from '@/composables/useSaUser'
import AppIcon from './AppIcon.vue'
import RankBadge from './RankBadge.vue'
interface Props {
big?: boolean
@@ -15,52 +13,31 @@ withDefaults(defineProps<Props>(), {
big: false,
placeholder: '닉네임으로 전적을 검색하세요',
})
const emit = defineEmits<{ (e: 'pick', player: Player): void }>()
const router = useRouter()
const { searchOuid, loading } = useSaUser()
const q = ref('')
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const matches = computed<Player[]>(() => {
const term = q.value.trim().toLowerCase()
if (!term) return []
return SA.allPlayers.filter((p) => p.name.toLowerCase().includes(term)).slice(0, 6)
})
const recents = computed<Player[]>(() =>
SA.recentSearches
.map((n) => SA.allPlayers.find((p) => p.name === n))
.filter((p): p is Player => Boolean(p)),
)
const list = computed(() => (q.value.trim() ? matches.value : recents.value))
//
async function submit() {
const name = q.value.trim()
//
if (!name || loading.value) return
function pick(p: Player) {
emit('pick', p)
const res = await searchOuid(name)
// ( )
if (!res) return
router.push({ name: 'profile', params: { name: res.userName } })
q.value = ''
open.value = false
router.push({ name: 'profile', params: { name: p.name } })
}
function submit() {
const found = matches.value[0] || SA.allPlayers.find((p) => p.name === q.value.trim())
if (found) pick(found)
else if (q.value.trim()) {
const fallback = SA.allPlayers[1] // :
if (fallback) pick(fallback)
}
}
function onDocPointer(e: PointerEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocPointer, true))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer, true))
</script>
<template>
<div
ref="root"
<form
class="sb"
:class="{ 'sb--big': big, 'sb--open': open }"
:class="{ 'sb--big': big }"
@submit.prevent="submit"
>
<div class="sb__field">
<div class="sb__icon">
@@ -74,61 +51,16 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
v-model="q"
class="sb__input"
:placeholder="placeholder"
@focus="open = true"
@input="open = true"
@keydown.enter="submit"
>
<button
type="submit"
class="sb__btn num"
@click="submit"
:disabled="loading"
>
검색
</button>
</div>
<div
v-if="open"
class="sb__pop"
>
<div class="sb__pophead">
{{ q.trim() ? `"${q}" 검색 결과` : '최근 검색한 유저' }}
</div>
<button
v-for="p in list"
:key="p.name"
class="sb__row"
@click="pick(p)"
>
<RankBadge
:rank="p.rank"
:size="32"
/>
<div class="sb__info">
<div class="sb__line">
<span class="sb__nm">{{ p.name }}</span>
<span
v-if="p.clan"
class="sb__clan"
>{{ p.clan }}</span>
</div>
<div class="sb__sub">
{{ p.rank.name }} · Lv.{{ p.level }} · 승률 {{ p.wr }}%
</div>
</div>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t3)"
/>
</button>
<div
v-if="q.trim() && matches.length === 0"
class="sb__empty"
>
일치하는 유저가 없어요. 닉네임을 확인해 주세요.
</div>
</div>
</div>
</form>
</template>
<style scoped>
@@ -139,9 +71,9 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
.sb__field {
display: flex;
align-items: center;
height: 44px;
height: 2.75rem;
background: var(--surf);
border: 1.5px solid var(--line2);
border: 0.09375rem solid var(--line2);
border-radius: var(--r-xs);
box-shadow: var(--sh-card);
transition:
@@ -150,21 +82,22 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
overflow: hidden;
}
.sb--big .sb__field {
height: 58px;
height: 3.625rem;
}
.sb--open .sb__field {
/* 포커스 시 강조 — 기존 sb--open 효과를 입력 포커스로 대체 */
.sb__field:focus-within {
border-color: var(--point);
box-shadow: var(--sh-pop);
}
.sb__icon {
padding-left: 13px;
padding-left: 0.8125rem;
color: var(--t3);
display: flex;
}
.sb--big .sb__icon {
padding-left: 18px;
padding-left: 1.125rem;
}
.sb--open .sb__icon {
.sb__field:focus-within .sb__icon {
color: var(--point);
}
.sb__input {
@@ -175,13 +108,13 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
color: #15171c;
font-family: var(--font-body);
font-size: 0.90625rem;
padding: 0 11px;
padding: 0 0.6875rem;
font-weight: 500;
min-width: 0;
}
.sb--big .sb__input {
font-size: 1.0625rem;
padding: 0 16px;
padding: 0 1rem;
}
.sb__btn {
height: 100%;
@@ -191,84 +124,15 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
background: var(--point);
font-weight: 700;
font-size: 0.8125rem;
padding: 0 17px;
padding: 0 1.0625rem;
letter-spacing: 0.5px;
}
.sb__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sb--big .sb__btn {
font-size: 0.9375rem;
padding: 0 26px;
}
.sb__pop {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-xs);
box-shadow: 0 16px 40px rgba(20, 22, 28, 0.12);
padding: 7px;
z-index: 40;
overflow: hidden;
text-align: left;
}
.sb__pophead {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
padding: 6px 10px 8px;
letter-spacing: 0.3px;
}
.sb__row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: none;
background: transparent;
padding: 8px 10px;
border-radius: var(--r-xs);
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.sb__row:hover {
background: #f5f6f8;
}
.sb__info {
flex: 1;
min-width: 0;
}
.sb__line {
display: flex;
align-items: baseline;
gap: 6px;
}
.sb__nm {
color: #15171c;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__clan {
font-size: 0.6875rem;
color: var(--t2);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__sub {
font-size: 0.71875rem;
color: var(--t3);
}
.sb__empty {
padding: 14px 10px;
color: var(--t3);
font-size: 0.8125rem;
padding: 0 1.625rem;
}
</style>
+18 -3
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
// + (+). sa-profile-sections.jsx TierChipS .
// ( ) + (+). sa-profile-sections.jsx TierChipS .
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
interface Props {
name: string
div?: number | null
// URL ( )
image?: string
}
const props = withDefaults(defineProps<Props>(), { div: null })
const props = withDefaults(defineProps<Props>(), { div: null, image: '' })
const col = computed(() => {
const t = (SA.tiers || []).find((x) => x.name === props.name)
@@ -17,7 +19,14 @@ const col = computed(() => {
<template>
<span class="tc">
<img
v-if="image"
:src="image"
:alt="name"
class="tc__img"
>
<span
v-else
class="tc__mark"
:style="{ background: col }"
/>
@@ -32,6 +41,12 @@ const col = computed(() => {
gap: 7px;
white-space: nowrap;
}
.tc__img {
width: 1.375rem;
height: 1.375rem;
object-fit: contain;
flex-shrink: 0;
}
.tc__mark {
width: 9px;
height: 9px;
+2 -3
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
// + . sa-shared.jsx WinRing .
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
@@ -14,12 +13,12 @@ const props = withDefaults(defineProps<Props>(), {
size: 64,
sw: 6,
label: '승률',
color: '', //
color: '', // ( )
})
const radius = computed(() => (props.size - props.sw) / 2)
const circ = computed(() => 2 * Math.PI * radius.value)
const col = computed(() => props.color || wrColor(props.wr))
const col = computed(() => props.color || 'var(--point)')
const dashOffset = computed(() => circ.value * (1 - props.wr / 100))
</script>
+5 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// + . . sa-profile-sections.jsx WrCell .
// ( / ) .
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
@@ -10,7 +10,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { width: 46 })
const fill = computed(() => Math.max(0.04, Math.min(1, (props.wr - 35) / (80 - 35))))
const col = computed(() => wrColor(props.wr))
</script>
<template>
@@ -21,13 +20,10 @@ const col = computed(() => wrColor(props.wr))
>
<span
class="wrc__fill"
:style="{ width: fill * 100 + '%', background: col }"
:style="{ width: fill * 100 + '%' }"
/>
</span>
<span
class="wrc__pct num"
:style="{ color: col }"
>{{ wr }}%</span>
<span class="wrc__pct num">{{ wr }}%</span>
</div>
</template>
@@ -49,10 +45,12 @@ const col = computed(() => wrColor(props.wr))
.wrc__fill {
display: block;
height: 100%;
background: var(--point);
}
.wrc__pct {
font-weight: 700;
min-width: 36px;
text-align: right;
color: var(--ink);
}
</style>
@@ -56,4 +56,17 @@ const links = ['이용약관', '개인정보처리방침', '공지사항', '문
color: var(--t3);
line-height: 1.7;
}
/* 반응형 — 좁은 화면(<600px)에서 세로 정렬, 카피라이트 좌측 정렬 (원본 .sg-footer @600) */
@media (max-width: 600px) {
.footer__inner {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.footer__copy {
margin-left: 0;
text-align: left;
}
}
</style>
@@ -3,10 +3,14 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLogo from '@/shared/components/AppLogo.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
const route = useRoute()
const router = useRouter()
// (<760px) (2219). CSS prop .
const { mobile } = useBreakpoint()
interface NavItem {
label: string
to: string
@@ -38,7 +42,7 @@ function go(to: string) {
to="/"
class="nav__logo"
>
<AppLogo :size="22" />
<AppLogo :size="mobile ? 19 : 22" />
</RouterLink>
<nav class="nav__menu">
<button
@@ -99,4 +103,23 @@ function go(to: string) {
color: var(--point);
border-bottom-color: var(--point);
}
/* 반응형 — 모바일(<760px)에서 nav 간격/항목 축소 및 가로 스크롤 허용 (로고 크기는 prop으로 분기) */
@media (max-width: 760px) {
.nav__logo {
flex-shrink: 0;
}
.nav__menu {
margin-left: 12px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.nav__item {
padding: 0 11px;
font-size: 0.875rem;
white-space: nowrap;
flex-shrink: 0;
}
}
</style>
@@ -61,4 +61,17 @@ const ui = useUiStore()
.topbar__link:hover {
color: #fff;
}
/* 반응형 — 모바일(<760px)에서 링크 폰트·구분선 간격 축소 (원본 로그인 버튼 마진/폰트 축소 대응) */
@media (max-width: 760px) {
.topbar__inner {
padding: 0 12px;
}
.topbar__sep {
margin: 0 8px;
}
.topbar__link {
font-size: 0.6875rem;
}
}
</style>
+382
View File
@@ -0,0 +1,382 @@
// 클랜/모집 도메인 mock 데이터 및 유틸 (엠블럼 도형, 색 보정, 클랜 조회, 모집 생성)
import type { Rank } from './types'
// ─────────────────────────────────────────────────────────────
// 엠블럼
// ─────────────────────────────────────────────────────────────
/** 엠블럼 설정 — ClanEmblem.vue가 소비 (모든 필드 optional 허용) */
export interface ClanEmblem {
/** 도형 — EMBLEM_SHAPES 키 또는 'circle'(별도 렌더) */
shape: string
/** 배경 기준 색 (HEX) */
bg: string
/** 심볼 종류 */
symbol: string
/** 심볼 색 */
symbolColor: string
}
/** 엠블럼 도형 path(d) 맵 — viewBox 0~100. 'circle'은 컴포넌트가 별도 처리하므로 제외 */
export const EMBLEM_SHAPES: Record<string, string> = {
shield: 'M50 8 L86 22 V52 C86 74 70 86 50 94 C30 86 14 74 14 52 V22 Z',
hexagon: 'M50 6 L88 28 V72 L50 94 L12 72 V28 Z',
diamond: 'M50 6 L92 50 L50 94 L8 50 Z',
pentagon: 'M50 6 L92 38 L76 90 H24 L8 38 Z',
}
/** HEX 색 밝기 보정 — amount 양수면 밝게, 음수면 어둡게 */
export function shade(hex: string, amount: number): string {
const h = hex.replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
const num = parseInt(full, 16)
const clamp = (v: number) => Math.max(0, Math.min(255, v))
const r = clamp((num >> 16) + amount)
const g = clamp(((num >> 8) & 0xff) + amount)
const b = clamp((num & 0xff) + amount)
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
// ─────────────────────────────────────────────────────────────
// 클랜 타입
// ─────────────────────────────────────────────────────────────
/** 요일별 매치 활동량 — keyof로 카테고리 순회하므로 number 4필드만 유지 */
export interface ClanActivityDay {
clanWar: number
clanRanked: number
soloRanked: number
partyRanked: number
}
/** 클랜 공지 1건 */
export interface ClanNoticeItem {
title: string
author: string
time: string
/** 상단 고정 여부 */
pin: boolean
}
/** 클랜 피드 1건 */
export interface ClanFeedItem {
author: string
time: string
text: string
likes: number
role?: string
}
/** 방명록 답글 */
export interface ClanGuestReply {
author: string
text: string
time: string
}
/** 방명록 1건 */
export interface ClanGuestItem {
author: string
text: string
time: string
replies: ClanGuestReply[]
}
/** 주간 일정 1건 (요일 7개 고정) */
export interface ClanScheduleItem {
/** 요일 단일 글자 (월~일) */
day: string
time: string
title: string
/** 활성 여부 */
on: boolean
}
/** 클랜원 1명 */
export interface ClanMember {
/** 직책 — '클랜장' | '운영진' | '정예' | '일반' */
role: string
name: string
/** 계급 (없으면 미표기) */
rank?: Rank
/** 기여도 */
contrib: number
}
/** 클랜 종합 데이터 */
export interface Clan {
name: string
tag?: string
/** 내 클랜 여부 (작성/등록 권한 분기) */
isMine: boolean
slogan: string
intro: string
/** 클랜 랭킹 */
rank: number
totalMembers: number
memberMax: number
/** 창설일 */
founded: string
/** 누적 경험치 */
expTotal: number
/** 이번 주 경험치 */
expWeek: number
emblem: ClanEmblem
/** 요일별 접속률(%) 길이 7 (월~일) */
attendance: number[]
/** 요일별 활동 길이 7 (월~일) */
activity: ClanActivityDay[]
notices: ClanNoticeItem[]
feed: ClanFeedItem[]
guestbook: ClanGuestItem[]
/** 주간 일정 (월~일 7개) */
schedule: ClanScheduleItem[]
members: ClanMember[]
}
// ─────────────────────────────────────────────────────────────
// 모집 타입
// ─────────────────────────────────────────────────────────────
/** 가입 조건 */
export interface RecruitConditions {
/** 최소 연령 (제한 없으면 null) */
ageMin: number | null
/** 최대 연령 (제한 없으면 null) */
ageMax: number | null
/** 보이스 필수 */
voice: boolean
/** 닉네임 변경 필요 */
nickChange: boolean
/** 이중클랜/부계 금지 */
noDualClan: boolean
/** 지인 동반 가입 가능 */
friendJoin: boolean
}
/** 문의 채널 */
export interface RecruitContact {
type: '디스코드' | '오픈카톡'
label: string
}
/** 모집 공고 1건 */
export interface Recruit {
name: string
emblem: ClanEmblem
/** 모집 유형 (RECRUIT_TYPES 부분집합) */
recruitTypes: string[]
intro: string
/** 클랜 랭킹 */
rank: number
/** 주간 승률(%) */
weeklyWr: number
record: { matches: number; w: number; l: number }
/** 현재 인원 */
cur: number
/** 정원 */
max: number
/** 남은 자리 */
openings: number
/** 인기 점수 (정렬용) */
hot: number
/** 작성 경과(분) */
createdAgo: number
conditions: RecruitConditions
contacts: RecruitContact[]
}
/** 모집 유형 목록 ('전체'는 미포함 — 사용처에서 앞에 붙임) */
export const RECRUIT_TYPES = ['클랜전', '랭크전', '친목', '대회준비', '초보환영', '고인물'] as const
// ─────────────────────────────────────────────────────────────
// 결정론적 생성 유틸
// ─────────────────────────────────────────────────────────────
function hashSeed(str: string): number {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
return h >>> 0
}
function rng(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function ri(r: () => number, min: number, max: number): number {
return Math.floor(r() * (max - min + 1)) + min
}
function pick<T>(r: () => number, arr: readonly T[]): T {
return arr[Math.floor(r() * arr.length)] as T
}
const DAYS = ['월', '화', '수', '목', '금', '토', '일']
const EMBLEM_BGS = ['#48515f', '#7a3b3b', '#2f5d7a', '#3b6a3f', '#6a4a8a', '#8a6a2a', '#2a6a6a']
const EMBLEM_SHAPE_KEYS = ['shield', 'hexagon', 'diamond', 'pentagon', 'circle']
const EMBLEM_SYMBOLS = ['crosshair', 'star', 'skull', 'wolf', 'sword', 'crown', 'flame', 'bolt', 'shieldcheck', 'anchor']
const NICKS = ['심해', '강철늑대', '백발백중', '저격수K', '그림자', '불꽃', '레이븐', '겨울', '에이스', '광속', '폭풍', '제로']
const CLANS = ['불곰부대', 'NOXIA', '백호단', '서리늑대', '검은방패', 'OVERDRIVE', '한울', '레이지', '나이트폴', '청룡']
const RANK_PRESETS: Rank[] = [
{ name: '병장', color: '#4f86f7', pips: 4, shape: 'bar', group: '훈련병' },
{ name: '상사', color: '#3aa657', pips: 3, shape: 'chev', group: '부사관' },
{ name: '대위', color: '#e8762a', pips: 3, shape: 'dia', group: '위관' },
{ name: '대령', color: '#a8369c', pips: 3, shape: 'flower', group: '영관' },
{ name: '준장', color: '#f0c14b', pips: 1, shape: 'star', group: '장성' },
]
function makeEmblem(r: () => number): ClanEmblem {
return {
shape: pick(r, EMBLEM_SHAPE_KEYS),
bg: pick(r, EMBLEM_BGS),
symbol: pick(r, EMBLEM_SYMBOLS),
symbolColor: '#ffffff',
}
}
// ─────────────────────────────────────────────────────────────
// 클랜 조회
// ─────────────────────────────────────────────────────────────
/** 클랜명으로 클랜 데이터 조회 (데모: 존재하지 않아도 생성 반환) */
export function ClanDB(name: string): Clan {
const r = rng(hashSeed('clan-' + name))
const isMine = name === '불곰부대'
const members: ClanMember[] = [
{ role: '클랜장', name: pick(r, NICKS), rank: pick(r, RANK_PRESETS), contrib: ri(r, 40000, 90000) },
...Array.from({ length: ri(r, 2, 4) }, () => ({
role: '운영진',
name: pick(r, NICKS) + ri(r, 1, 99),
rank: pick(r, RANK_PRESETS),
contrib: ri(r, 20000, 50000),
})),
...Array.from({ length: ri(r, 4, 8) }, () => ({
role: '정예',
name: pick(r, NICKS) + ri(r, 1, 99),
rank: pick(r, RANK_PRESETS),
contrib: ri(r, 8000, 24000),
})),
...Array.from({ length: ri(r, 6, 12) }, () => ({
role: '일반',
name: pick(r, NICKS) + ri(r, 1, 99),
contrib: ri(r, 500, 8000),
})),
]
const leader = members[0] as ClanMember
return {
name,
tag: name.slice(0, 3).toUpperCase(),
isMine,
slogan: pick(r, ['우리는 한 팀, 끝까지 간다', '실력보다 매너', '랭킹 1위를 향해', '즐겜과 빡겜 사이']),
intro: `${name}은(는) ${ri(r, 2020, 2024)}년에 창설된 클랜입니다. 클랜전과 랭크전을 함께 즐기며 꾸준히 성장하고 있습니다. 매너 있고 적극적인 분들을 환영합니다.`,
rank: ri(r, 1, 240),
totalMembers: members.length,
memberMax: 30,
founded: `202${ri(r, 0, 4)}.0${ri(r, 1, 9)}.${ri(r, 10, 28)}`,
expTotal: ri(r, 200000, 2400000),
expWeek: ri(r, 4000, 42000),
emblem: makeEmblem(r),
attendance: DAYS.map(() => ri(r, 30, 95)),
activity: DAYS.map(() => ({
clanWar: ri(r, 0, 12),
clanRanked: ri(r, 0, 10),
soloRanked: ri(r, 0, 18),
partyRanked: ri(r, 0, 14),
})),
notices: [
{ title: '이번 주 클랜전 일정 공지', author: leader.name, time: '2일 전', pin: true },
{ title: '클랜 규칙 안내 (필독)', author: leader.name, time: '1주 전', pin: true },
{ title: '정기 모임 결과 공유', author: pick(r, NICKS), time: '3일 전', pin: false },
{ title: '신규 클랜원 환영합니다', author: pick(r, NICKS), time: '5일 전', pin: false },
{ title: '랭크전 파티 모집', author: pick(r, NICKS), time: '6일 전', pin: false },
],
feed: Array.from({ length: 6 }, () => ({
author: pick(r, NICKS),
time: pick(r, ['방금 전', '1시간 전', '오늘', '어제', '2일 전']),
text: pick(r, [
'오늘 클랜전 다들 수고하셨습니다!',
'저녁 9시에 랭크 같이 돌릴 사람?',
'신규 맵 연습 좀 하고 싶은데 같이 하실 분',
'이번 주 승률 좋네요 ㅎㅎ',
'클랜전 라인업 공유합니다',
]),
likes: ri(r, 0, 24),
role: '일반',
})),
guestbook: Array.from({ length: 4 }, () => ({
author: pick(r, NICKS) + ri(r, 1, 99),
text: pick(r, ['가입 문의 드립니다!', '클랜전 잘 봤어요', '같이 게임해요~', '분위기 좋아 보이네요']),
time: pick(r, ['1시간 전', '오늘', '어제', '3일 전']),
replies:
r() > 0.5
? [{ author: leader.name, text: '감사합니다! 디스코드로 연락주세요', time: '방금 전' }]
: [],
})),
schedule: DAYS.map((day) => {
const on = r() > 0.4
return {
day,
time: on ? `${ri(r, 19, 23)}:00` : '—',
title: on ? pick(r, ['클랜전', '랭크 파티', '내전', '스크림', '자유참여']) : '휴식',
on,
}
}),
members,
}
}
// ─────────────────────────────────────────────────────────────
// 모집 공고 생성
// ─────────────────────────────────────────────────────────────
/** 연령 조건을 라벨 문자열로 변환 */
export function recruitAgeLabel(cond: RecruitConditions): string {
const { ageMin, ageMax } = cond
if (ageMin == null && ageMax == null) return '전연령'
if (ageMin != null && ageMax != null) return `${ageMin}~${ageMax}`
if (ageMin != null) return `${ageMin}세 이상`
return `${ageMax}세 이하`
}
/** 모집 공고 목록 생성 */
export function buildRecruits(): Recruit[] {
return CLANS.map((name) => {
const r = rng(hashSeed('recruit-' + name))
const max = pick(r, [20, 25, 30])
const cur = ri(r, Math.floor(max * 0.4), max - 1)
const matches = ri(r, 80, 1200)
const w = ri(r, Math.floor(matches * 0.42), Math.floor(matches * 0.58))
const ageMin = r() > 0.4 ? pick(r, [16, 18, 19, 20]) : null
const types = RECRUIT_TYPES.filter(() => r() > 0.5).slice(0, 3)
return {
name,
emblem: makeEmblem(r),
recruitTypes: types.length ? types : [pick(r, RECRUIT_TYPES)],
intro: `${name}에서 함께할 클랜원을 모집합니다. ${pick(r, ['클랜전 위주', '랭크전 위주', '친목 위주'])}로 활동하며, ${pick(r, ['초보 환영', '실력자 우대', '매너 중시'])}합니다.`,
rank: ri(r, 1, 240),
weeklyWr: ri(r, 42, 72),
record: { matches, w, l: matches - w },
cur,
max,
openings: max - cur,
hot: ri(r, 0, 100),
createdAgo: ri(r, 3, 720),
conditions: {
ageMin,
ageMax: null,
voice: r() > 0.5,
nickChange: r() > 0.7,
noDualClan: r() > 0.5,
friendJoin: r() > 0.4,
},
contacts: [
{ type: '디스코드', label: `${name} 디스코드` },
...(r() > 0.5 ? [{ type: '오픈카톡' as const, label: `${name} 오픈채팅` }] : []),
],
}
})
}
+197
View File
@@ -0,0 +1,197 @@
// 커뮤니티 도메인 mock 데이터 및 유틸 (게시판/게시글/댓글)
/** 파티 모집 정보 — CmPost.party */
export interface CmParty {
/** 모집 유형 (CM_PARTY_TYPES 원소) */
ptype: string
/** 보이스 필수 여부 */
voice: boolean
}
/** 댓글 답글 */
export interface CmReply {
author: string
text: string
time: string
}
/** 댓글 1건 */
export interface CmComment {
author: string
text: string
time: string
/** 추천수 */
up: number
replies: CmReply[]
/** 삭제 표시 */
deleted?: boolean
}
/** 게시글 1건 */
export interface CmPost {
id: number
/** 게시판명 (CM_BOARDS 원소) */
board: string
title: string
author: string
/** 상대 시간 표기 */
time: string
/** 경과 시간(시간 단위) */
hoursAgo: number
views: number
/** 댓글 수 */
cmt: number
/** 추천수 */
up: number
/** HOT 여부 */
hot: boolean
/** 사진 포함 여부 */
hasImage: boolean
/** 본문 */
body: string
/** 파티 모집 정보 (없으면 null) */
party: CmParty | null
}
/** 게시판 목록 ('전체'는 미포함 — 사용처에서 앞에 붙임) */
export const CM_BOARDS = ['자유', '공략', '파티 모집', '클랜', '질문', '유머']
/** 파티 모집 유형 목록 */
export const CM_PARTY_TYPES = ['클랜전', '랭크전', '일반전', '내전', '대회']
/** 베스트 게시글 추천 임계값 */
export const CM_BEST_UP = 30
/** 상단 공지 목록 */
export const CM_NOTICES: { title: string; time: string }[] = [
{ title: '[공지] 커뮤니티 이용 규칙 안내', time: '상시' },
{ title: '[공지] 비매너/도배 신고 방법', time: '05.20' },
{ title: '[이벤트] 베스트 글 작성자 보상 지급', time: '06.01' },
]
// ─────────────────────────────────────────────────────────────
// 결정론적 생성 유틸
// ─────────────────────────────────────────────────────────────
function hashSeed(str: string): number {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
return h >>> 0
}
function rng(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function ri(r: () => number, min: number, max: number): number {
return Math.floor(r() * (max - min + 1)) + min
}
function pick<T>(r: () => number, arr: readonly T[]): T {
return arr[Math.floor(r() * arr.length)] as T
}
const AUTHORS = ['심해', '강철늑대', '백발백중', '저격수K', '그림자', '불꽃', '레이븐', '겨울', '익명의총잡이', '뉴비1234']
const VERIFIED = new Set(['심해', '강철늑대', '백발백중', '저격수K', '불꽃', '레이븐'])
const TITLES = [
'오늘 랭크 올린 후기 공유합니다',
'신규 맵 앵글 정리해봄',
'클랜전 같이 하실 분 구해요',
'저격 감도 추천 부탁드립니다',
'이번 패치 무기 밸런스 어떰?',
'폭파미션 기본기 강좌',
'연승 중인데 팁 공유함',
'에임 연습 루틴 정리',
'비매너 유저 후기',
'신규 캐릭터 성능 리뷰',
'주말 내전 모집합니다',
'랭크 듀오 구해요 (보이스 필수)',
]
const BODIES = [
'오늘 드디어 목표 티어 달성했습니다! 꾸준히 하니까 되네요. 다들 화이팅하세요.',
'이 맵에서 자주 쓰는 앵글 몇 개 정리해봤습니다. 스크린샷은 댓글에 추가할게요.',
'저녁에 같이 게임하실 분 구합니다. 분위기 좋게 즐겜 위주로 해요.',
'감도 세팅 공유합니다. 본인한테 맞게 조금씩 조정하는 걸 추천드려요.',
'이번 패치로 메타가 좀 바뀐 것 같은데 여러분 생각은 어떠신가요?',
]
function makePost(id: number, board: string, isParty: boolean): CmPost {
const r = rng(hashSeed('post-' + id))
const up = ri(r, 0, 80)
return {
id,
board,
title: pick(r, TITLES),
author: pick(r, AUTHORS),
time: pick(r, ['방금 전', '12분 전', '1시간 전', '3시간 전', '오늘', '어제']),
hoursAgo: ri(r, 0, 72),
views: ri(r, 30, 24000),
cmt: ri(r, 0, 60),
up,
hot: up >= CM_BEST_UP || r() > 0.8,
hasImage: r() > 0.6,
body: pick(r, BODIES),
party: isParty
? { ptype: pick(r, CM_PARTY_TYPES), voice: r() > 0.5 }
: null,
}
}
/** 전체 게시글 목록 */
export const CM_POSTS: CmPost[] = Array.from({ length: 48 }, (_, i) => {
const r = rng(hashSeed('board-' + i))
const isParty = r() > 0.7
const board = isParty ? '파티 모집' : pick(r, CM_BOARDS.filter((b) => b !== '파티 모집'))
return makePost(1000 + i, board, isParty)
})
// ─────────────────────────────────────────────────────────────
// 조회 유틸
// ─────────────────────────────────────────────────────────────
/** 게시글 본문 반환 */
export function cmBody(p: CmPost): string {
return p.body
}
/** 게시글의 댓글 목록 반환 (id 기반 결정론적 생성) */
export function cmComments(p: CmPost): CmComment[] {
const count = Math.min(p.cmt, 12)
return Array.from({ length: count }, (_, i) => {
const r = rng(hashSeed('cmt-' + p.id + '-' + i))
return {
author: pick(r, AUTHORS),
text: pick(r, [
'좋은 정보 감사합니다!',
'ㄹㅇ 공감되네요',
'저도 같은 생각이에요',
'도움 많이 됐습니다 ㅎㅎ',
'혹시 감도 얼마 쓰시나요?',
'같이 게임해요~',
]),
time: pick(r, ['방금 전', '5분 전', '30분 전', '1시간 전', '오늘']),
up: ri(r, 0, 18),
replies:
r() > 0.7
? [{ author: p.author, text: '댓글 감사합니다!', time: '방금 전' }]
: [],
}
})
}
/** 파티 정보 → 배지 문자열 배열 */
export function cmPartyBadges(party: CmParty): string[] {
return [party.ptype, party.voice ? '보이스 필수' : '보이스 자유']
}
/** 닉네임 인증(공식 유저) 여부 */
export function cmVerified(name: string): boolean {
return VERIFIED.has(name)
}
+455
View File
@@ -0,0 +1,455 @@
// 서든어택 전적 사이트 mock 데이터 허브 (`SA`) + 플레이어 조회 유틸
// 실제 API 연동 전까지 사용하는 데모용 데이터. 시드 기반 결정론적 생성으로 매 빌드 동일.
import type {
Player,
Rank,
StreamPlatform,
MatchRecord,
RosterPlayer,
RankedSeason,
TrendPoint,
} from './types'
// ─────────────────────────────────────────────────────────────
// 결정론적 의사난수 (문자열 시드 기반 mulberry32)
// ─────────────────────────────────────────────────────────────
function hashSeed(str: string): number {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
return h >>> 0
}
function rng(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** [min, max] 정수 난수 */
function ri(r: () => number, min: number, max: number): number {
return Math.floor(r() * (max - min + 1)) + min
}
/** 배열에서 하나 선택 */
function pick<T>(r: () => number, arr: T[]): T {
return arr[Math.floor(r() * arr.length)] as T
}
// ─────────────────────────────────────────────────────────────
// 계급(Rank) 프리셋
// ─────────────────────────────────────────────────────────────
const RANKS: Record<string, Rank> = {
: { name: '이병', color: '#9aa4b2', pips: 1, shape: 'bar', group: '훈련병' },
: { name: '일병', color: '#8b97c7', pips: 2, shape: 'bar', group: '훈련병' },
: { name: '상병', color: '#6f86d6', pips: 3, shape: 'bar', group: '훈련병' },
: { name: '병장', color: '#4f86f7', pips: 4, shape: 'bar', group: '훈련병' },
: { name: '하사', color: '#37c0a8', pips: 1, shape: 'chev', group: '부사관' },
: { name: '중사', color: '#2fb37f', pips: 2, shape: 'chev', group: '부사관' },
: { name: '상사', color: '#3aa657', pips: 3, shape: 'chev', group: '부사관' },
: { name: '소위', color: '#d8a73a', pips: 1, shape: 'dia', group: '위관' },
: { name: '중위', color: '#e0922e', pips: 2, shape: 'dia', group: '위관' },
: { name: '대위', color: '#e8762a', pips: 3, shape: 'dia', group: '위관' },
: { name: '소령', color: '#d65a7a', pips: 1, shape: 'flower', group: '영관' },
: { name: '중령', color: '#c8417a', pips: 2, shape: 'flower', group: '영관' },
: { name: '대령', color: '#a8369c', pips: 3, shape: 'flower', group: '영관' },
: { name: '준장', color: '#f0c14b', pips: 1, shape: 'star', group: '장성' },
: { name: '소장', color: '#f5b323', pips: 2, shape: 'star', group: '장성' },
: { name: '중장', color: '#ff9d2f', pips: 3, shape: 'star', group: '장성' },
}
const RANK_LADDER = Object.values(RANKS)
// ─────────────────────────────────────────────────────────────
// 티어(랭크전) 프리셋
// ─────────────────────────────────────────────────────────────
const TIERS = [
{ name: '브론즈', color: '#b08d57' },
{ name: '실버', color: '#9aa4b2' },
{ name: '골드', color: '#e0a93a' },
{ name: '플래티넘', color: '#3fb6a8' },
{ name: '다이아', color: '#5b9bf0' },
{ name: '마스터', color: '#c057d6' },
{ name: '그랜드마스터', color: '#ff5c7a' },
]
const MAPS_5V5 = ['제3보급창고', '웨어하우스', '서드포트', '데드엔드', '오피스']
const MAPS_3V3 = ['포트', '컨테이너', '하이드아웃']
const MODE_NAMES = ['폭파미션', '데스매치', '점령전', '감염모드', '대인전']
const MATCH_MODES = ['클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트']
const NICK_POOL = [
'심해', '독수리', '그림자', '바람', '강철늑대', '한방러', '저격수K', '불꽃', '제로', '광속',
'야간조', '백발백중', '검은표범', '폭풍', '에이스', 'no_scope', '한타장인', '무한리필', '겨울', '레이븐',
]
const CLAN_POOL = ['불곰부대', 'NOXIA', '백호단', '서리늑대', '검은방패', 'OVERDRIVE']
// ─────────────────────────────────────────────────────────────
// 표기 유틸 (SA 메서드로 노출)
// ─────────────────────────────────────────────────────────────
function saFmt(n: number): string {
return n.toLocaleString('en-US')
}
function saKdPct(kda: number): number {
return Math.round((kda / (kda + 1)) * 100)
}
// ─────────────────────────────────────────────────────────────
// 로스터/매치 생성
// ─────────────────────────────────────────────────────────────
function makeRoster(r: () => number, ownerName: string, ownerIsHere: boolean): RosterPlayer[] {
return Array.from({ length: 5 }, (_, i) => {
const isOwner = ownerIsHere && i === 0
return {
rank: pick(r, RANK_LADDER),
name: isOwner ? ownerName : pick(r, NICK_POOL) + ri(r, 1, 99),
isOwner,
k: ri(r, 5, 28),
d: ri(r, 4, 20),
a: ri(r, 0, 10),
hs: ri(r, 20, 65),
dmg: ri(r, 1800, 6200),
}
})
}
function makeMatches(r: () => number, name: string, count: number): MatchRecord[] {
return Array.from({ length: count }, () => {
const mode = pick(r, MATCH_MODES)
const is5v5 = mode !== '토너먼트'
const map = pick(r, is5v5 ? MAPS_5V5 : MAPS_3V3)
const win = r() > 0.45
const winSide: 'blue' | 'red' = win ? 'blue' : 'red'
return {
mode,
win,
map,
k: ri(r, 8, 30),
d: ri(r, 6, 22),
a: ri(r, 0, 9),
ago: pick(r, ['방금 전', '12분 전', '1시간 전', '3시간 전', '어제', '2일 전']),
winSide,
teams: {
blue: makeRoster(r, name, true),
red: makeRoster(r, name, false),
},
}
})
}
function makeTrend(r: () => number, n: number, weekly: boolean): TrendPoint[] {
return Array.from({ length: n }, (_, i) => ({
wr: ri(r, 38, 72),
kd: ri(r, 40, 70),
label: weekly ? `${i + 1}` : `${i + 1}`,
}))
}
function makeRankedSeasons(r: () => number): RankedSeason[] {
const seasons = ['2024 S1', '2024 S2', '2025 S1']
const mk = () => {
const matches = ri(r, 40, 260)
const w = ri(r, Math.floor(matches * 0.4), Math.floor(matches * 0.6))
const l = matches - w
const tier = pick(r, TIERS).name
return {
matches,
w,
l,
wr: Math.round((w / matches) * 100),
kd: ri(r, 45, 65),
hs: ri(r, 25, 55),
tier,
div: ri(r, 1, 4),
points: ri(r, 800, 2400),
}
}
return seasons.map((season) => ({ season, solo: mk(), party: mk() }))
}
function makeRecords(r: () => number, names: string[]) {
return names.map((name) => {
const matches = ri(r, 30, 400)
const w = ri(r, Math.floor(matches * 0.4), Math.floor(matches * 0.6))
const l = matches - w
return { name, matches, w, l, wr: Math.round((w / matches) * 100), kd: ri(r, 42, 68) }
})
}
// ─────────────────────────────────────────────────────────────
// 플레이어 팩토리
// ─────────────────────────────────────────────────────────────
interface PlayerSeed {
name: string
clan?: string | null
rankNoTotal?: number
rankNoSeason?: number
}
function makePlayer(seed: PlayerSeed): Player {
const r = rng(hashSeed(seed.name))
const wins = ri(r, 600, 9800)
const losses = ri(r, 500, 8200)
const wr = Math.round((wins / (wins + losses)) * 100)
const rankTotal = RANK_LADDER[ri(r, 4, RANK_LADDER.length - 1)] as Rank
const rankSeason = RANK_LADDER[ri(r, 4, RANK_LADDER.length - 1)] as Rank
return {
name: seed.name,
clan: seed.clan ?? null,
rank: rankTotal,
rankTotal,
rankSeason,
rankNoTotal: seed.rankNoTotal ?? ri(r, 1, 90000),
rankNoSeason: seed.rankNoSeason ?? ri(r, 1, 90000),
level: ri(r, 30, 220),
wins,
losses,
wr,
kda: +(r() * 1.6 + 0.7).toFixed(2),
hs: ri(r, 28, 62),
rifle: ri(r, 40, 75),
sniper: ri(r, 15, 50),
special: ri(r, 5, 25),
manner: ri(r, 78, 100),
connRate: ri(r, 80, 99),
views: ri(r, 1200, 480000),
contrib: ri(r, 2000, 90000),
matches: makeMatches(r, seed.name, 14),
rankedSeasons: makeRankedSeasons(r),
clanRecords: (seed.clan ? [seed.clan] : []).concat(pick(r, CLAN_POOL)).map((clan, i) => {
const matches = ri(r, 60, 900)
const w = ri(r, Math.floor(matches * 0.42), Math.floor(matches * 0.58))
const l = matches - w
return {
clan,
tag: clan.slice(0, 3).toUpperCase(),
matches,
w,
l,
wr: Math.round((w / matches) * 100),
kd: ri(r, 45, 65),
periods: [
{ from: `202${3 + i}.0${ri(r, 1, 9)}`, to: i === 0 ? '활동중' : `202${4 + i}.0${ri(r, 1, 9)}`, months: ri(r, 2, 18) },
],
}
}),
modeRecords: makeRecords(r, MODE_NAMES),
mapRecords: makeRecords(r, [...MAPS_5V5, ...MAPS_3V3]),
trend: {
daily: makeTrend(r, 14, false),
weekly: makeTrend(r, 12, true),
},
together: (() => {
const games = ri(r, 8, 120)
const w = ri(r, Math.floor(games * 0.4), Math.floor(games * 0.6))
return { games, w, l: games - w }
})(),
}
}
// ─────────────────────────────────────────────────────────────
// 플레이어 풀 구성
// ─────────────────────────────────────────────────────────────
const PLAYER_SEEDS: PlayerSeed[] = [
{ name: '심해', clan: '불곰부대', rankNoTotal: 12, rankNoSeason: 8 },
{ name: '강철늑대', clan: 'NOXIA', rankNoTotal: 27, rankNoSeason: 19 },
{ name: '백발백중', clan: '백호단', rankNoTotal: 41, rankNoSeason: 33 },
{ name: '저격수K', clan: '서리늑대', rankNoTotal: 58, rankNoSeason: 52 },
{ name: '그림자', clan: null, rankNoTotal: 73, rankNoSeason: 64 },
{ name: '불꽃', clan: '검은방패', rankNoTotal: 88, rankNoSeason: 79 },
{ name: '레이븐', clan: 'OVERDRIVE', rankNoTotal: 104, rankNoSeason: 95 },
{ name: '겨울', clan: '불곰부대', rankNoTotal: 121, rankNoSeason: 110 },
{ name: '에이스', clan: 'NOXIA', rankNoTotal: 143, rankNoSeason: 128 },
{ name: '한타장인', clan: null, rankNoTotal: 167, rankNoSeason: 151 },
{ name: '검은표범', clan: '백호단', rankNoTotal: 192, rankNoSeason: 174 },
{ name: '광속', clan: '서리늑대', rankNoTotal: 215, rankNoSeason: 199 },
]
const allPlayers: Player[] = PLAYER_SEEDS.map(makePlayer)
const playerByName = new Map(allPlayers.map((p) => [p.name, p]))
/** 내 계정 (데모) — 친구의 together 정보 부여 */
const me: Player = {
...makePlayer({ name: '심해', clan: '불곰부대', rankNoTotal: 12, rankNoSeason: 8 }),
}
/** 친구 목록 — together(함께한 전적) 부여 */
const friends: Player[] = allPlayers.slice(1, 7).map((p) => {
const r = rng(hashSeed('together-' + p.name))
const games = ri(r, 8, 120)
const w = ri(r, Math.floor(games * 0.4), Math.floor(games * 0.6))
return { ...p, together: { games, w, l: games - w } }
})
// ─────────────────────────────────────────────────────────────
// 랭킹 데이터
// ─────────────────────────────────────────────────────────────
const classRanking = (offset: number) =>
allPlayers
.map((p, i) => ({ name: p.name, score: 9800 - i * 137 - offset }))
.sort((a, b) => b.score - a.score)
const rpRanking = (seed: string) => {
const r = rng(hashSeed(seed))
return allPlayers
.map((p) => ({ name: p.name, tier: pick(r, TIERS).name, rp: ri(r, 1200, 2600) }))
.sort((a, b) => b.rp - a.rp)
}
const clanRanking = (seed: string) => {
const r = rng(hashSeed(seed))
return CLAN_POOL.map((name) => ({
name,
tag: name.slice(0, 3).toUpperCase(),
members: ri(r, 12, 60),
rp: ri(r, 8000, 24000),
})).sort((a, b) => b.rp - a.rp)
}
// ─────────────────────────────────────────────────────────────
// SA — mock 데이터 허브
// ─────────────────────────────────────────────────────────────
export const SA = {
/** 천 단위 콤마 포맷 */
fmt: saFmt,
/** KDA → 킬뎃 백분율(정수) */
kdPct: saKdPct,
me,
allPlayers,
favorites: allPlayers.slice(0, 8),
friends,
/** 티어 색상표 */
tiers: TIERS,
// 계급 랭킹
totalClassRanking: classRanking(0),
seasonClassRanking: classRanking(420),
// 랭크전 RP 랭킹
soloRpRanking: rpRanking('solo-rp'),
partyRpRanking: rpRanking('party-rp'),
// 클랜 랭킹
clanRpRanking: clanRanking('clan-rp'),
officialClanRanking: clanRanking('official-clan'),
generalClanRanking: clanRanking('general-clan'),
/** 최근 검색 닉네임 */
recentSearches: ['강철늑대', '저격수K', '그림자', '불꽃', '에이스', '겨울'],
/** 실시간 검색 추세 */
searchTrending: [
{ name: '강철늑대', move: 2 as number | 'new', count: 12840 },
{ name: '백발백중', move: 'new' as number | 'new', count: 9320 },
{ name: '심해', move: 0 as number | 'new', count: 8710 },
{ name: '불꽃', move: -1 as number | 'new', count: 7220 },
{ name: '레이븐', move: 5 as number | 'new', count: 6105 },
{ name: '광속', move: -3 as number | 'new', count: 4980 },
],
/** 연승 플레이어 */
hotStreak: [
{ name: '강철늑대', streak: 11 },
{ name: '심해', streak: 8 },
{ name: '에이스', streak: 7 },
{ name: '불꽃', streak: 5 },
{ name: '레이븐', streak: 4 },
],
/** 뉴스 (탭별) */
news: {
: [
{ date: '06.10', title: '2025 시즌2 랭크전 보상 안내' },
{ date: '06.08', title: '정기 점검 안내 (06/12 02:00~06:00)' },
{ date: '06.05', title: '비매너 이용자 제재 정책 강화' },
{ date: '06.01', title: '6월 출석 이벤트 오픈' },
],
GM이야기: [
{ date: '06.09', title: 'GM이 직접 플레이한 신규 맵 후기' },
{ date: '06.03', title: '밸런스 패치 방향성 공유' },
{ date: '05.28', title: '커뮤니티 피드백 반영 현황' },
],
: [
{ date: '06.11', title: '신규 캐릭터 "서리" 출시' },
{ date: '06.07', title: '무기 밸런스 조정 v3.2' },
{ date: '06.02', title: '클랜 시스템 개편 적용' },
],
} as Record<string, { date: string; title: string }[]>,
/** 실시간 통계 */
liveStats: {
totalMatches: 1842301,
prevDelta: 4.2,
bannedToday: 318,
matchModes: [
{ name: '폭파미션', count: 921400 },
{ name: '데스매치', count: 412800 },
{ name: '점령전', count: 286500 },
{ name: '감염모드', count: 221601 },
],
},
/** 시즌 정보 */
season: {
dday: 'D-12',
name: '2025 시즌2',
period: '06.30',
progress: 68,
maps5v5: MAPS_5V5,
maps3v3: MAPS_3V3,
},
/** 방송 목록 */
streams: [
{ platform: '치지직', streamer: '강철늑대', viewers: 4210, title: '랭크 올리기 가즈아', started: '2시간 전' },
{ platform: '치지직', streamer: '심해', viewers: 1880, title: '시청자 클랜전', started: '40분 전' },
{ platform: '숲', streamer: '백발백중', viewers: 3120, title: '저격 원챔 방송', started: '1시간 전' },
{ platform: '숲', streamer: '불꽃', viewers: 980, title: '데스매치 연승 도전', started: '20분 전' },
{ platform: '유튜브', streamer: '레이븐', viewers: 2540, title: '신규 맵 공략', started: '3시간 전' },
] as { platform: string; streamer: string; viewers: number; title: string; started: string }[],
/** 방송 플랫폼 표기 */
streamPlatforms: {
: { color: '#00ffa3', tag: 'CHZ', label: '치지직' },
: { color: '#0064ff', tag: 'SOOP', label: '숲(아프리카)' },
: { color: '#ff0033', tag: 'YT', label: '유튜브' },
} as Record<string, StreamPlatform>,
/** 커뮤니티 미리보기 (게시판별) */
community: {
: [
{ tag: '자유', title: '오늘 클랜전 진짜 재밌었다', hot: true, cmt: 24, time: '12분 전' },
{ tag: '자유', title: '신규 맵 어떰?', hot: false, cmt: 8, time: '30분 전' },
{ tag: '자유', title: '랭크 올리는 팁 공유함', hot: true, cmt: 41, time: '1시간 전' },
{ tag: '자유', title: '저격 감도 추천좀', hot: false, cmt: 12, time: '2시간 전' },
],
: [
{ tag: '공략', title: '제3보급창고 앵글 정리', hot: true, cmt: 33, time: '40분 전' },
{ tag: '공략', title: '폭파미션 라인전 기본기', hot: false, cmt: 15, time: '3시간 전' },
{ tag: '공략', title: '에임 연습 루틴 공유', hot: false, cmt: 9, time: '5시간 전' },
],
} as Record<string, { tag: string; title: string; hot: boolean; cmt: number; time: string }[]>,
/** 이벤트 스트립 */
events: [
{ title: '6월 출석 이벤트', sub: '매일 접속하고 보상 받기', period: '06.01~06.30', dday: 'D-19', tag: 'EVENT' },
{ title: '시즌2 랭크 보상', sub: '티어별 한정 무기 지급', period: '~06.30', dday: 'D-12', tag: 'SEASON' },
{ title: '신규 캐릭터 출시 기념', sub: '"서리" 픽업 한정 패키지', period: '~06.20', dday: 'D-9', tag: 'NEW' },
{ title: '주말 경험치 2배', sub: '토·일 전 모드 적용', period: '매주 주말', dday: 'D-2', tag: 'BONUS' },
] as { title: string; sub: string; period: string; dday: string; tag: string; image?: string; link?: string }[],
}
/** 닉네임으로 플레이어 조회 (없으면 undefined) */
export function findPlayer(name: string): Player | undefined {
return playerByName.get(name)
}
+229
View File
@@ -0,0 +1,229 @@
// 공용 도메인 타입 정의 모듈 (mock 데이터 및 컴포넌트가 공유하는 순수 타입)
// 값(데이터)은 포함하지 않고 타입만 정의한다.
/** 계급(랭크) 뱃지 데이터 — RankBadge.vue가 소비 */
export interface Rank {
/** 계급 약칭 (방패 내부 텍스트) */
name: string
/** 계급 대표 색 (CSS color, hex 권장 — 알파 합성 `${color}16` 사용) */
color: string
/** 계급 표식(pip) 개수 */
pips: number
/** pip 도형 종류 — 그 외 값은 기본 막대(bar)로 렌더 */
shape: 'star' | 'flower' | 'dia' | 'chev' | 'bar'
/** 계급 그룹명 (showName 시 하단 표기) */
group: string
/** 계급 이미지 URL (있으면 SVG 뱃지 대신 이미지로 표시 — 넥슨 메타 연동용) */
image?: string
}
/** 방송 플랫폼 표기 정보 — StreamLive.vue가 소비 */
export interface StreamPlatform {
/** 플랫폼 색상 */
color: string
/** 짧은 태그 (썸네일 우상단 배지) */
tag: string
/** 플랫폼 라벨 (메타 줄 표기) */
label: string
}
/** 매치(전적) 1건 — Player.matches 원소 */
export interface MatchRecord {
/** 매치 식별자 (상세 lazy 로드용 — 실데이터 연동 시 사용) */
matchId?: string
/** 매치 모드 (예: '클랜전' | '솔로 랭크전' | '파티 랭크전' | '클랜 랭크전' | '토너먼트') */
mode: string
/** 승리 여부 */
win: boolean
/** 맵 이름 */
map: string
/** 킬 */
k: number
/** 데스 */
d: number
/** 어시스트 */
a: number
/** 경과 표기 (예: '3시간 전') */
ago: string
/** 승리 진영 */
winSide: 'blue' | 'red'
/** 양 팀 로스터 */
teams: {
blue: RosterPlayer[]
red: RosterPlayer[]
}
}
/** 매치 로스터 1명 — MatchRecord.teams.blue/red 원소, TeamTable.vue가 소비 */
export interface RosterPlayer {
/** 계급 */
rank: Rank
/** 플레이어 이름 */
name: string
/** 프로필 주인공(ME) 여부 */
isOwner: boolean
/** 킬 */
k: number
/** 데스 */
d: number
/** 어시스트 */
a: number
/** 헤드샷(%) */
hs: number
/** 데미지 */
dmg: number
}
/** 클랜별 전적 — Player.clanRecords 원소 */
export interface ClanRecord {
/** 클랜 이름 */
clan: string
/** 클랜 태그 */
tag: string
/** 총 매치 수 */
matches: number
/** 승 */
w: number
/** 패 */
l: number
/** 승률(%) */
wr: number
/** 킬뎃(%) */
kd: number
/** 활동 기간 목록 */
periods: {
from: string
to: string
months: number
}[]
}
/** 모드별/맵별 전적 — Player.modeRecords / mapRecords 원소 */
export interface ModeRecord {
/** 모드/맵 이름 */
name: string
/** 매치 수 */
matches: number
/** 승 */
w: number
/** 패 */
l: number
/** 승률(%) */
wr: number
/** 킬뎃(%) */
kd: number
}
/** 맵별 전적 (ModeRecord와 동일 구조) */
export type MapRecord = ModeRecord
/** 랭크전 시즌 단위 전적 (솔로/파티 각각) — RankedSeason.solo / party */
export interface RankedRecord {
/** 매치 수 */
matches: number
/** 승 */
w: number
/** 패 */
l: number
/** 승률(%) */
wr: number
/** 킬뎃(%) */
kd: number
/** 헤드샷(%) */
hs: number
/** 티어 이름 */
tier: string
/** 티어 디비전 (없으면 null) */
div: number | null
/** 티어 점수(RP) */
points: number
/** 티어 이미지 URL (넥슨 메타 연동 — 있으면 마름모 대신 이미지 표시) */
tierImage?: string
}
/** 랭크전 시즌 — Player.rankedSeasons 원소 */
export interface RankedSeason {
/** 시즌 식별자 (예: '2024 S1') */
season: string
/** 솔로 랭크전 전적 */
solo: RankedRecord
/** 파티 랭크전 전적 */
party: RankedRecord
}
/** 추세 그래프 1포인트 — Player.trend.daily / weekly 원소 */
export interface TrendPoint {
/** 승률(%) */
wr: number
/** 킬뎃(%) */
kd: number
/** x축 라벨 (날짜/주차) */
label: string
}
/** 플레이어 종합 데이터 — 프로필/홈/검색 등 광범위 소비 */
export interface Player {
/** 닉네임 */
name: string
/** 소속 클랜명 (없으면 빈 문자열 또는 null) */
clan?: string | null
/** 대표 계급 (프로필 accent 등) */
rank: Rank
/** 통합 계급 */
rankTotal: Rank
/** 시즌 계급 */
rankSeason: Rank
/** 통합 랭킹 순위 */
rankNoTotal: number
/** 시즌 랭킹 순위 */
rankNoSeason: number
/** 레벨 */
level: number
/** 승 */
wins: number
/** 패 */
losses: number
/** 승률(%) */
wr: number
/** KDA 수치 */
kda: number
/** 헤드샷(%) */
hs: number
/** 라이플(%) */
rifle: number
/** 스나이퍼(%) */
sniper: number
/** 특수전(%) */
special: number
/** 매너 점수 (mock) */
manner: number
/** 매너 등급 텍스트 (넥슨 API manner_grade — 예: '최고') */
mannerGrade?: string
/** 접속률(%) */
connRate: number
/** 프로필 조회수 */
views: number
/** 클랜 기여도 */
contrib: number
/** 매치 기록 */
matches: MatchRecord[]
/** 랭크전 시즌 기록 */
rankedSeasons: RankedSeason[]
/** 클랜별 전적 */
clanRecords: ClanRecord[]
/** 모드별 전적 */
modeRecords: ModeRecord[]
/** 맵별 전적 */
mapRecords: MapRecord[]
/** 추세 (일별/주별) */
trend: {
daily: TrendPoint[]
weekly: TrendPoint[]
}
/** 함께한 전적 (친구 목록 등에서 사용) */
together: {
games: number
w: number
l: number
}
}
+10
View File
@@ -0,0 +1,10 @@
// 넥슨 실데이터 → 기존 mock UI(Player/Rank) 어댑터 유틸
// UI 구조를 그대로 유지하기 위해 실데이터를 mock 타입 형태로 변환할 때 사용한다.
import type { Rank } from '@/shared/mock/types'
// 계급/시즌계급 명(문자열)으로 RankBadge 표시용 Rank 객체 합성.
// 넥슨 메타 계급 이미지가 있으면 image 로 넘겨 RankBadge 가 이미지를 표시한다.
// 이미지가 없을 때를 대비해 color 는 (알파 합성 가능한) hex 로 둔다.
export function makeRank(name: string, image = ''): Rank {
return { name: name || '-', color: '#48515f', pips: 0, shape: 'bar', group: '', image }
}