Compare commits
No commits in common. "29524af253f9b9137e670e5fcf1e2f48514db0d8" and "c5ca4034428a2bfadb97204e7436d3ed62a1ee6d" have entirely different histories.
29524af253
...
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 { 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()) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<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('');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user