autoCoupon: alliance 연맹원 목록 연동 추가
- /api/alliances: alliance 백엔드 프록시 - /api/alliance-members: R5 PIN 인증 후 연맹원 목록 반환 (쿠폰 등록 여부 포함) - AlliancePanel.vue: 연맹 선택 + R5 PIN 인증 + 역할별 필터 + 쿠폰 등록 현황 - HomeView.vue에 AlliancePanel 추가
This commit is contained in:
parent
872aabf0e9
commit
c5ca403442
@ -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 axios from 'axios';
|
||||
|
||||
const ALLIANCE_API = 'http://localhost:3005/api';
|
||||
|
||||
@Controller('api')
|
||||
export class WosController {
|
||||
@ -59,4 +62,25 @@ export class WosController {
|
||||
async deleteCoupon(@Param('id') id: string) {
|
||||
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()) }));
|
||||
}
|
||||
}
|
||||
|
||||
326
front/src/components/AlliancePanel.vue
Normal file
326
front/src/components/AlliancePanel.vue
Normal 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>
|
||||
@ -61,6 +61,8 @@
|
||||
<div class="card admin-card">
|
||||
<CouponManager />
|
||||
</div>
|
||||
|
||||
<AlliancePanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -72,6 +74,7 @@ import { useWosStore } from '../stores/wos.store';
|
||||
import UserCard from '../components/UserCard.vue';
|
||||
import CouponInput from '../components/CouponInput.vue';
|
||||
import CouponManager from '../components/CouponManager.vue';
|
||||
import AlliancePanel from '../components/AlliancePanel.vue';
|
||||
|
||||
const store = useWosStore();
|
||||
const fidInput = ref('');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user