쿠폰 자동 입력 사이트 작성
배포 완료됐습니다. 변경 내용 요약: --- ## 변경 사항 **자동화 (캡차 없음)** - FID 입력 → 조회 버튼 클릭 → 쿠폰 코드 붙여넣기 → ⚡ 자동 입력 클릭 - 백엔드가 캡차 세션을 자동으로 획득해서 처리 (사용자가 캡차를 볼 필요 없음) - 캡차 세션 만료 시 자동 재시도 **영구 이력 저장** - 쿠폰 입력할 때마다 DB(`coupon_logs`)에 저장 - FID 조회 시 해당 플레이어의 이력이 하단에 자동 로드 - 날짜/시간 + 결과 (성공/실패) 표시, 최대 100건 **https://coupon.wageulwageul.com** 에서 바로 확인하세요.
This commit is contained in:
@@ -1,51 +1,41 @@
|
||||
<template>
|
||||
<div class="coupon-input">
|
||||
<div class="captcha-section" v-if="store.captchaImg">
|
||||
<img :src="store.captchaImg" alt="캡차" class="captcha-img" />
|
||||
<div class="captcha-row">
|
||||
<input
|
||||
v-model="store.captchaInput"
|
||||
type="text"
|
||||
placeholder="캡차 입력"
|
||||
class="input captcha-field"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="store.loadCaptcha">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-load" v-else>
|
||||
<button class="btn btn-secondary" @click="store.loadCaptcha" :disabled="!store.fid">
|
||||
캡차 불러오기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="store.couponCodes"
|
||||
placeholder="쿠폰 코드를 한 줄에 하나씩 입력하세요"
|
||||
class="textarea"
|
||||
rows="6"
|
||||
rows="5"
|
||||
:disabled="store.status === 'loading'"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="submit"
|
||||
:disabled="store.status === 'loading' || !store.captchaInput || !store.fid"
|
||||
@click="store.submitCoupons()"
|
||||
:disabled="store.status === 'loading' || !store.fid"
|
||||
>
|
||||
<span v-if="store.status === 'loading'">처리 중...</span>
|
||||
<span v-else>쿠폰 입력</span>
|
||||
<span v-if="store.status === 'loading'">⏳ 처리 중...</span>
|
||||
<span v-else>⚡ 자동 입력</span>
|
||||
</button>
|
||||
|
||||
<p class="error-msg" v-if="store.errorMsg">{{ store.errorMsg }}</p>
|
||||
|
||||
<div class="results" v-if="store.results.length > 0">
|
||||
<div
|
||||
v-for="(r, i) in store.results"
|
||||
:key="i"
|
||||
class="result-row"
|
||||
:class="r.status"
|
||||
>
|
||||
<span class="code">{{ r.code }}</span>
|
||||
<span class="badge" :class="r.status">{{ r.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWosStore } from '../stores/wos.store';
|
||||
const store = useWosStore();
|
||||
|
||||
function submit() {
|
||||
store.submitCoupons();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -54,23 +44,6 @@ function submit() {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.captcha-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.captcha-img {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
}
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.captcha-field {
|
||||
flex: 1;
|
||||
}
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
@@ -83,55 +56,39 @@ function submit() {
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.input {
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.textarea:focus { outline: none; border-color: #60a5fa; }
|
||||
.textarea:disabled { opacity: 0.5; }
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #475569;
|
||||
}
|
||||
.captcha-load {
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #2563eb; }
|
||||
.error-msg { color: #f87171; font-size: 0.9rem; margin: 0; }
|
||||
.results { display: flex; flex-direction: column; gap: 6px; }
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
.code { font-family: monospace; color: #60a5fa; font-size: 0.9rem; }
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.success { background: #166534; color: #4ade80; }
|
||||
.badge.error { background: #7f1d1d; color: #f87171; }
|
||||
.badge.pending { background: #1e3a5f; color: #93c5fd; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="history" v-if="store.history.length > 0">
|
||||
<div class="history-header">
|
||||
<h3>📋 입력 이력</h3>
|
||||
<span class="count">총 {{ store.history.length }}건</span>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="item in store.history"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
:class="statusClass(item.result_msg)"
|
||||
>
|
||||
<div class="item-left">
|
||||
<span class="code">{{ item.coupon_code }}</span>
|
||||
<span class="time">{{ formatDate(item.executed_at) }}</span>
|
||||
</div>
|
||||
<span class="badge" :class="statusClass(item.result_msg)">{{ item.result_msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWosStore } from '../stores/wos.store';
|
||||
const store = useWosStore();
|
||||
|
||||
function statusClass(msg: string) {
|
||||
if (msg === '성공') return 'success';
|
||||
if (msg.includes('사용') || msg.includes('만료') || msg.includes('존재하지')) return 'error';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function formatDate(dt: string) {
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.history-header h3 {
|
||||
margin: 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.count {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.list::-webkit-scrollbar { width: 4px; }
|
||||
.list::-webkit-scrollbar-track { background: #0f172a; }
|
||||
.list::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #334155;
|
||||
}
|
||||
.item.success { border-left-color: #22c55e; }
|
||||
.item.error { border-left-color: #ef4444; }
|
||||
.item-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.code {
|
||||
font-family: monospace;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.time {
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.success { background: #166534; color: #4ade80; }
|
||||
.badge.error { background: #7f1d1d; color: #f87171; }
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@ interface PlayerInfo {
|
||||
nickname: string;
|
||||
avatar_image: string;
|
||||
stove_lv: number;
|
||||
kid: string;
|
||||
}
|
||||
|
||||
interface RedeemResult {
|
||||
@@ -14,14 +13,21 @@ interface RedeemResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
fid: string;
|
||||
coupon_code: string;
|
||||
result_msg: string;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export const useWosStore = defineStore('wos', {
|
||||
state: () => ({
|
||||
fid: '',
|
||||
player: null as PlayerInfo | null,
|
||||
captchaImg: '',
|
||||
captchaInput: '',
|
||||
couponCodes: '',
|
||||
results: [] as RedeemResult[],
|
||||
history: [] as HistoryItem[],
|
||||
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
|
||||
errorMsg: '',
|
||||
}),
|
||||
@@ -30,31 +36,30 @@ export const useWosStore = defineStore('wos', {
|
||||
async searchPlayer(fid: string) {
|
||||
this.status = 'loading';
|
||||
this.errorMsg = '';
|
||||
this.player = null;
|
||||
this.results = [];
|
||||
try {
|
||||
const { data } = await axios.post('/api/player', { fid });
|
||||
this.player = data;
|
||||
this.fid = fid;
|
||||
this.status = 'success';
|
||||
this.status = 'idle';
|
||||
await this.loadHistory();
|
||||
} catch (err: any) {
|
||||
this.player = null;
|
||||
this.status = 'error';
|
||||
this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.';
|
||||
}
|
||||
},
|
||||
|
||||
async loadCaptcha() {
|
||||
async loadHistory() {
|
||||
if (!this.fid) return;
|
||||
try {
|
||||
const { data } = await axios.post('/api/captcha', { fid: this.fid });
|
||||
this.captchaImg = data.img ? `data:image/png;base64,${data.img}` : '';
|
||||
this.captchaInput = '';
|
||||
} catch (err: any) {
|
||||
this.errorMsg = err.response?.data?.message || '캡차 로드 실패';
|
||||
}
|
||||
const { data } = await axios.get(`/api/history/${this.fid}`);
|
||||
this.history = data;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async submitCoupons() {
|
||||
if (!this.fid || !this.captchaInput) return;
|
||||
if (!this.fid) return;
|
||||
const codes = this.couponCodes
|
||||
.split('\n')
|
||||
.map((c) => c.trim())
|
||||
@@ -70,16 +75,11 @@ export const useWosStore = defineStore('wos', {
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
const { data } = await axios.post('/api/redeem', {
|
||||
fid: this.fid,
|
||||
codes,
|
||||
captcha: this.captchaInput,
|
||||
});
|
||||
|
||||
const { data } = await axios.post('/api/redeem', { fid: this.fid, codes });
|
||||
this.results = data;
|
||||
this.status = 'success';
|
||||
this.captchaInput = '';
|
||||
this.captchaImg = '';
|
||||
this.status = 'idle';
|
||||
this.couponCodes = '';
|
||||
await this.loadHistory();
|
||||
} catch (err: any) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = err.response?.data?.message || '쿠폰 입력 중 오류가 발생했습니다.';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="home">
|
||||
<header>
|
||||
<h1>⚔️ WOS 쿠폰 자동 입력</h1>
|
||||
<p class="subtitle">Whiteout Survival 쿠폰 자동화 시스템</p>
|
||||
<p class="subtitle">FID 입력 후 쿠폰 코드를 넣으면 자동으로 처리됩니다</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
@@ -13,9 +13,11 @@
|
||||
placeholder="FID (플레이어 ID) 입력"
|
||||
class="input"
|
||||
@keydown.enter="searchPlayer"
|
||||
:disabled="store.status === 'loading'"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="searchPlayer" :disabled="store.status === 'loading'">
|
||||
조회
|
||||
<span v-if="store.status === 'loading' && !store.player">조회 중...</span>
|
||||
<span v-else>조회</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="error-msg" v-if="store.status === 'error' && !store.player">{{ store.errorMsg }}</p>
|
||||
@@ -24,10 +26,14 @@
|
||||
<UserCard v-if="store.player" :player="store.player" :fid="store.fid" />
|
||||
|
||||
<div class="card" v-if="store.player">
|
||||
<h3 class="section-title">쿠폰 입력</h3>
|
||||
<CouponInput />
|
||||
</div>
|
||||
|
||||
<ResultTable :results="store.results" />
|
||||
<div class="card" v-if="store.player">
|
||||
<HistoryList />
|
||||
<p class="empty-history" v-if="store.history.length === 0">아직 입력 이력이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +42,7 @@ import { ref } from 'vue';
|
||||
import { useWosStore } from '../stores/wos.store';
|
||||
import UserCard from '../components/UserCard.vue';
|
||||
import CouponInput from '../components/CouponInput.vue';
|
||||
import ResultTable from '../components/ResultTable.vue';
|
||||
import HistoryList from '../components/HistoryList.vue';
|
||||
|
||||
const store = useWosStore();
|
||||
const fidInput = ref('');
|
||||
@@ -51,27 +57,35 @@ async function searchPlayer() {
|
||||
.home {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
padding: 32px 16px 60px;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.9rem;
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
@@ -86,10 +100,8 @@ header h1 {
|
||||
color: #f1f5f9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.input:focus { outline: none; border-color: #60a5fa; }
|
||||
.input:disabled { opacity: 0.5; }
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
@@ -97,22 +109,11 @@ header h1 {
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.9rem;
|
||||
margin: 8px 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #2563eb; }
|
||||
.error-msg { color: #f87171; font-size: 0.9rem; margin: 8px 0 0; }
|
||||
.empty-history { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user