UI 작업
This commit is contained in:
Generated
+57
-19
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)에서 로고 크기 축소(22→19). 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>
|
||||
|
||||
@@ -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} 오픈채팅` }] : []),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user