Compare commits

...

No commits in common. "29524af253f9b9137e670e5fcf1e2f48514db0d8" and "c5ca4034428a2bfadb97204e7436d3ed62a1ee6d" have entirely different histories.

3 changed files with 354 additions and 1 deletions

View File

@ -1,5 +1,8 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode } from '@nestjs/common'; import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode, BadRequestException, ForbiddenException } from '@nestjs/common';
import { WosService } from './wos.service'; import { WosService } from './wos.service';
import axios from 'axios';
const ALLIANCE_API = 'http://localhost:3005/api';
@Controller('api') @Controller('api')
export class WosController { export class WosController {
@ -59,4 +62,25 @@ export class WosController {
async deleteCoupon(@Param('id') id: string) { async deleteCoupon(@Param('id') id: string) {
return this.wosService.deleteCoupon(Number(id)); return this.wosService.deleteCoupon(Number(id));
} }
@Get('alliances')
async getAlliances() {
const { data } = await axios.get(`${ALLIANCE_API}/alliances`);
return data;
}
@Post('alliance-members')
@HttpCode(200)
async getAllianceMembers(@Body() body: { alliance_id: number; pin: string }) {
if (!body.alliance_id || !body.pin) throw new BadRequestException('alliance_id와 pin이 필요합니다');
try {
await axios.get(`${ALLIANCE_API}/alliances/${body.alliance_id}/pending`, { params: { pin: body.pin } });
} catch {
throw new ForbiddenException('PIN이 틀렸습니다');
}
const { data: members } = await axios.get(`${ALLIANCE_API}/members`, { params: { alliance_id: body.alliance_id } });
const wosUsers = await this.wosService.listUsers();
const nicknameSet = new Set(wosUsers.map((u: any) => u.nickname?.toLowerCase()));
return members.map((m: any) => ({ ...m, in_coupon_system: nicknameSet.has(m.nickname?.toLowerCase()) }));
}
} }

View File

@ -0,0 +1,326 @@
<template>
<div class="alliance-panel">
<div class="panel-header" @click="expanded = !expanded">
<span> 연맹원 목록</span>
<span class="toggle-arrow" :class="{ open: expanded }"></span>
</div>
<div v-if="expanded" class="panel-body">
<div v-if="!authed" class="auth-section">
<div class="field-row">
<select v-model="selectedAllianceId" class="select-input" :disabled="loadingAlliances">
<option value="">연맹 선택...</option>
<option v-for="a in alliances" :key="a.id" :value="a.id">
[{{ a.tag }}] {{ a.name }} (서버{{ a.server_number }})
</option>
</select>
</div>
<div class="field-row">
<input v-model="pin" type="password" class="pin-input" placeholder="R5 PIN 입력" @keyup.enter="doAuth" />
<button class="btn-auth" @click="doAuth" :disabled="authing || !selectedAllianceId || !pin.trim()">
{{ authing ? '확인중...' : '확인' }}
</button>
</div>
<div v-if="authError" class="error-msg">{{ authError }}</div>
</div>
<div v-else class="members-section">
<div class="members-header">
<span class="alliance-badge"> {{ currentAlliance?.tag }} {{ currentAlliance?.name }}</span>
<div class="header-actions">
<input v-model="memberSearch" class="search-input" placeholder="닉네임 검색..." />
<button class="btn-logout" @click="logout">나가기</button>
</div>
</div>
<div class="role-filter">
<button
v-for="r in roleFilters"
:key="r"
:class="['role-btn', { active: selectedRole === r }]"
@click="selectedRole = r"
>{{ r }}</button>
</div>
<div class="members-stats">
<span class="stat">전체 {{ filteredMembers.length }}</span>
<span class="stat linked"> 쿠폰 등록: {{ linkedCount }}</span>
<span class="stat unlinked"> 미등록: {{ filteredMembers.length - linkedCount }}</span>
</div>
<div class="member-list">
<div
v-for="m in filteredMembers"
:key="m.id"
:class="['member-row', { linked: m.in_coupon_system }]"
>
<span :class="['role-tag', roleClass(m.role)]">{{ m.role }}</span>
<span class="unit-icon">{{ unitIcon(m.main_unit) }}</span>
<span class="nickname">{{ m.nickname }}</span>
<span class="power">{{ formatPower(m.power) }}</span>
<span v-if="m.in_coupon_system" class="coupon-badge">쿠폰 등록됨</span>
<span v-else class="coupon-miss">미등록</span>
</div>
<div v-if="filteredMembers.length === 0" class="empty">검색 결과가 없습니다</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
interface Alliance {
id: number
server_number: number
tag: string
name: string
}
interface AllianceMember {
id: number
nickname: string
role: string
main_unit: string
power: number
in_coupon_system: boolean
}
const expanded = ref(false)
const alliances = ref<Alliance[]>([])
const loadingAlliances = ref(false)
const selectedAllianceId = ref<number | ''>('')
const pin = ref('')
const authing = ref(false)
const authError = ref('')
const authed = ref(false)
const currentAlliance = ref<Alliance | null>(null)
const members = ref<AllianceMember[]>([])
const memberSearch = ref('')
const selectedRole = ref('전체')
const roleFilters = ['전체', 'R5', 'R4', 'R3', 'R2', 'R1']
const filteredMembers = computed(() => {
let list = members.value
if (selectedRole.value !== '전체') list = list.filter((m) => m.role === selectedRole.value)
const q = memberSearch.value.trim().toLowerCase()
if (q) list = list.filter((m) => m.nickname.toLowerCase().includes(q))
return list
})
const linkedCount = computed(() => filteredMembers.value.filter((m) => m.in_coupon_system).length)
onMounted(async () => {
loadingAlliances.value = true
try {
const { data } = await axios.get('/api/alliances')
alliances.value = data
} finally {
loadingAlliances.value = false
}
})
async function doAuth() {
if (!selectedAllianceId.value || !pin.value.trim()) return
authing.value = true
authError.value = ''
try {
const { data } = await axios.post('/api/alliance-members', {
alliance_id: selectedAllianceId.value,
pin: pin.value.trim(),
})
members.value = data
currentAlliance.value = alliances.value.find((a) => a.id === selectedAllianceId.value) ?? null
authed.value = true
} catch (e: any) {
authError.value = e?.response?.data?.message || 'PIN이 틀렸거나 오류가 발생했습니다'
} finally {
authing.value = false
}
}
function logout() {
authed.value = false
members.value = []
pin.value = ''
authError.value = ''
currentAlliance.value = null
}
function roleClass(role: string) {
return { R5: 'r5', R4: 'r4', R3: 'r3', R2: 'r2', R1: 'r1' }[role] ?? ''
}
function unitIcon(u: string) {
return { infantry: '🛡', lancer: '🗡', marksman: '🏹', mixed: '⚔️' }[u] ?? '⚔️'
}
function formatPower(p: number) {
if (!p) return '-'
if (p >= 1_000_000_000) return (p / 1_000_000_000).toFixed(1) + 'B'
if (p >= 1_000_000) return (p / 1_000_000).toFixed(1) + 'M'
if (p >= 1_000) return (p / 1_000).toFixed(1) + 'K'
return String(p)
}
</script>
<style scoped>
.alliance-panel {
background: #1e1e2e;
border: 1px solid #2a2a3e;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
cursor: pointer;
font-weight: 700;
font-size: 0.95rem;
color: #cdd6f4;
user-select: none;
transition: background 0.1s;
}
.panel-header:hover { background: #252535; }
.toggle-arrow { font-size: 0.85rem; color: #6c7086; transition: transform 0.2s; }
.toggle-arrow.open { transform: rotate(180deg); }
.panel-body { padding: 16px; }
.auth-section { display: flex; flex-direction: column; gap: 10px; }
.field-row { display: flex; gap: 8px; }
.select-input {
flex: 1;
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 9px 12px;
font-size: 0.88rem;
}
.select-input:focus { outline: none; border-color: #89b4fa; }
.pin-input {
flex: 1;
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 9px 12px;
font-size: 0.88rem;
}
.pin-input:focus { outline: none; border-color: #89b4fa; }
.btn-auth {
background: #1e3a8a;
color: #93c5fd;
border: none;
border-radius: 8px;
padding: 9px 16px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.btn-auth:hover:not(:disabled) { background: #1d4ed8; }
.btn-auth:disabled { opacity: 0.4; cursor: not-allowed; }
.error-msg { color: #f38ba8; font-size: 0.82rem; padding: 4px 0; }
.members-section { display: flex; flex-direction: column; gap: 12px; }
.members-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.alliance-badge { font-weight: 700; color: #89b4fa; font-size: 0.9rem; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.search-input {
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 7px 12px;
font-size: 0.85rem;
width: 150px;
}
.search-input:focus { outline: none; border-color: #89b4fa; }
.btn-logout {
background: transparent;
color: #6c7086;
border: 1px solid #313244;
border-radius: 8px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-logout:hover { color: #f38ba8; border-color: #f38ba8; }
.role-filter { display: flex; gap: 6px; flex-wrap: wrap; }
.role-btn {
background: #181825;
border: 1.5px solid #313244;
color: #6c7086;
border-radius: 20px;
padding: 4px 12px;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.role-btn:hover { border-color: #89b4fa; color: #89b4fa; }
.role-btn.active { background: #1e3a5f; border-color: #89b4fa; color: #89b4fa; }
.members-stats { display: flex; gap: 12px; font-size: 0.8rem; }
.stat { color: #6c7086; }
.stat.linked { color: #a6e3a1; }
.stat.unlinked { color: #fab387; }
.member-list { display: flex; flex-direction: column; gap: 2px; max-height: 340px; overflow-y: auto; }
.member-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #181825;
font-size: 0.85rem;
transition: background 0.1s;
}
.member-row.linked { background: #0f2010; }
.member-row:hover { background: #252535; }
.member-row.linked:hover { background: #152a15; }
.role-tag {
padding: 2px 7px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 800;
min-width: 28px;
text-align: center;
}
.role-tag.r5 { background: #3b1f00; color: #fab387; }
.role-tag.r4 { background: #1e1b4b; color: #c4b5fd; }
.role-tag.r3 { background: #1e2d3e; color: #89b4fa; }
.role-tag.r2 { background: #1c2a1c; color: #a6e3a1; }
.role-tag.r1 { background: #252535; color: #6c7086; }
.unit-icon { font-size: 0.9rem; }
.nickname { flex: 1; color: #cdd6f4; font-weight: 600; }
.power { color: #6c7086; font-size: 0.8rem; min-width: 50px; text-align: right; }
.coupon-badge {
background: #132010;
color: #a6e3a1;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 700;
}
.coupon-miss {
color: #6c7086;
font-size: 0.72rem;
}
.empty { color: #6c7086; text-align: center; padding: 16px 0; font-size: 0.85rem; }
</style>

View File

@ -61,6 +61,8 @@
<div class="card admin-card"> <div class="card admin-card">
<CouponManager /> <CouponManager />
</div> </div>
<AlliancePanel />
</div> </div>
</div> </div>
</div> </div>
@ -72,6 +74,7 @@ import { useWosStore } from '../stores/wos.store';
import UserCard from '../components/UserCard.vue'; import UserCard from '../components/UserCard.vue';
import CouponInput from '../components/CouponInput.vue'; import CouponInput from '../components/CouponInput.vue';
import CouponManager from '../components/CouponManager.vue'; import CouponManager from '../components/CouponManager.vue';
import AlliancePanel from '../components/AlliancePanel.vue';
const store = useWosStore(); const store = useWosStore();
const fidInput = ref(''); const fidInput = ref('');