initial commit

This commit is contained in:
hyoseung 2026-05-11 16:35:26 +09:00
commit 6372c699e6
35 changed files with 8602 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
build/
.cache/
*.log
.env
*.pem
.DS_Store

9
back/.env.example Normal file
View File

@ -0,0 +1,9 @@
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=kakao
DB_PASSWORD=486251daKWON@
DB_DATABASE=kakao
APP_PORT=3000
DISCORD_WEBHOOK_URL=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=

6
back/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
build/
.cache/
*.log
.env

8
back/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

5303
back/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
back/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "auto-coupon-back",
"version": "1.0.0",
"description": "WOS 쿠폰 자동 입력 NestJS 백엔드",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^10.0.0",
"axios": "^1.6.0",
"mysql2": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"typeorm": "^0.3.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@types/node": "^20.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
}
}

27
back/src/app.module.ts Normal file
View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WosModule } from './wos/wos.module';
import { CrawlerModule } from './crawler/crawler.module';
import { WosUser } from './entities/wos-user.entity';
import { CouponLog } from './entities/coupon-log.entity';
import { WosCoupon } from './entities/wos-coupon.entity';
@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME || 'kakao',
password: process.env.DB_PASSWORD || '486251daKWON@',
database: process.env.DB_DATABASE || 'kakao',
entities: [WosUser, CouponLog, WosCoupon],
synchronize: true,
}),
WosModule,
CrawlerModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,13 @@
import { Controller, Post, HttpCode } from '@nestjs/common';
import { CrawlerService } from './crawler.service';
@Controller('api/crawler')
export class CrawlerController {
constructor(private readonly crawlerService: CrawlerService) {}
@Post('trigger')
@HttpCode(200)
async triggerCrawl() {
return this.crawlerService.crawlWiki();
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CrawlerController } from './crawler.controller';
import { CrawlerService } from './crawler.service';
import { WosCoupon } from '../entities/wos-coupon.entity';
import { WosModule } from '../wos/wos.module';
@Module({
imports: [TypeOrmModule.forFeature([WosCoupon]), WosModule],
controllers: [CrawlerController],
providers: [CrawlerService],
})
export class CrawlerModule {}

View File

@ -0,0 +1,113 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import { WosCoupon } from '../entities/wos-coupon.entity';
import { WosService } from '../wos/wos.service';
const WIKI_URL = 'https://www.whiteoutsurvival.wiki/giftcodes/';
@Injectable()
export class CrawlerService {
private readonly logger = new Logger(CrawlerService.name);
constructor(
@InjectRepository(WosCoupon)
private wosCouponRepo: Repository<WosCoupon>,
private readonly wosService: WosService,
) {}
private extractCodesFromHtml(html: string): string[] {
const found: string[] = [];
const pattern = /<span[^>]*class="code"[^>]*>([^<]+)<\/span>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
const code = match[1].trim();
if (code.length > 0) found.push(code);
}
return found;
}
async crawlWiki(): Promise<{ found: number; added: string[] }> {
this.logger.log('Wiki 쿠폰 크롤링 시작...');
let html: string;
try {
const resp = await axios.get(WIKI_URL, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html',
},
timeout: 10000,
});
html = resp.data as string;
} catch (err: any) {
this.logger.error(`Wiki 요청 실패: ${err.message}`);
return { found: 0, added: [] };
}
const wikiCodes = this.extractCodesFromHtml(html);
this.logger.log(`Wiki에서 ${wikiCodes.length}개 코드 발견`);
if (wikiCodes.length === 0) return { found: 0, added: [] };
const existingCoupons = await this.wosCouponRepo.find();
const existingCodes = new Set(existingCoupons.map((c) => c.code));
const addedCodes: string[] = [];
for (const code of wikiCodes) {
if (existingCodes.has(code)) continue;
this.logger.log(`새 쿠폰 발견: ${code}`);
try {
await this.wosService.addCoupon(`Wiki: ${code}`, code);
existingCodes.add(code);
addedCodes.push(code);
this.logger.log(`쿠폰 추가 완료: ${code}`);
} catch (err: any) {
this.logger.error(`쿠폰 추가 실패 [${code}]: ${err.message}`);
}
}
if (addedCodes.length > 0) {
await this.sendDiscordNotification(addedCodes);
}
this.logger.log(`Wiki 크롤링 완료 - 신규 쿠폰: ${addedCodes.length}`);
return { found: wikiCodes.length, added: addedCodes };
}
private async sendDiscordNotification(codes: string[]): Promise<void> {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) return;
const codeList = codes.map((c) => `\`${c}\``).join(', ');
const payload = {
username: 'WOS 쿠폰 봇',
avatar_url: 'https://wos-giftcode.centurygame.com/favicon.ico',
embeds: [
{
title: '🎁 새 WOS 쿠폰 발견!',
description: `Wiki에서 새로운 쿠폰 **${codes.length}개**를 찾았어요!\n\n${codeList}`,
color: 0x00b0f4,
footer: { text: 'WOS 쿠폰 자동 크롤러 | whiteoutsurvival.wiki' },
timestamp: new Date().toISOString(),
},
],
};
try {
await axios.post(webhookUrl, payload, { timeout: 5000 });
this.logger.log(`Discord 알림 전송 완료 (${codes.length}개 쿠폰)`);
} catch (err: any) {
this.logger.error(`Discord 알림 실패: ${err.message}`);
}
}
@Cron('0 0 * * * *')
async scheduledCrawl() {
await this.crawlWiki();
}
}

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('coupon_logs')
export class CouponLog {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 20 })
fid: string;
@Column({ length: 50 })
coupon_code: string;
@Column({ length: 100, nullable: true })
result_msg: string;
@CreateDateColumn()
executed_at: Date;
}

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('coupons')
export class Coupon {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, length: 100 })
code: string;
@Column({ length: 100, nullable: true })
name: string;
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
}

View File

@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('wos_coupons')
export class WosCoupon {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true, length: 50 })
code: string;
@Column({ default: true })
is_active: boolean;
@Column({ default: false })
is_expired: boolean;
@CreateDateColumn()
created_at: Date;
}

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('wos_users')
export class WosUser {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, length: 20 })
fid: string;
@Column({ length: 50, nullable: true })
nickname: string;
@Column({ type: 'text', nullable: true })
avatar_url: string;
@CreateDateColumn()
created_at: Date;
}

17
back/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: '*',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type'],
});
const port = process.env.APP_PORT || 3000;
await app.listen(port);
console.log(`Server running on port ${port}`);
}
bootstrap();

View File

@ -0,0 +1,62 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode } from '@nestjs/common';
import { WosService } from './wos.service';
@Controller('api')
export class WosController {
constructor(private readonly wosService: WosService) {}
@Post('player')
@HttpCode(200)
async getPlayer(@Body() body: { fid: string }) {
return this.wosService.getPlayer(body.fid);
}
@Post('redeem-all')
@HttpCode(200)
async redeemAll(@Body() body: { fid: string }) {
return this.wosService.redeemAll(body.fid);
}
@Post('redeem')
@HttpCode(200)
async redeem(@Body() body: { fid: string }) {
return this.wosService.redeemAll(body.fid);
}
@Get('users')
async listUsers() {
return this.wosService.listUsers();
}
@Get('users/search')
async searchUsers(@Query('q') q: string) {
if (!q || q.trim().length === 0) return [];
return this.wosService.searchUsersByNickname(q.trim());
}
@Get('history/:fid')
async getHistory(@Param('fid') fid: string) {
return this.wosService.getHistory(fid);
}
@Get('coupons')
async listCoupons() {
return this.wosService.listCoupons();
}
@Post('coupons')
@HttpCode(200)
async addCoupon(@Body() body: { name: string; code: string }) {
return this.wosService.addCoupon(body.name, body.code);
}
@Patch('coupons/:id')
async toggleCoupon(@Param('id') id: string, @Body() body: { is_active: boolean }) {
return this.wosService.toggleCoupon(Number(id), body.is_active);
}
@Delete('coupons/:id')
async deleteCoupon(@Param('id') id: string) {
return this.wosService.deleteCoupon(Number(id));
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WosController } from './wos.controller';
import { WosService } from './wos.service';
import { WosUser } from '../entities/wos-user.entity';
import { CouponLog } from '../entities/coupon-log.entity';
import { WosCoupon } from '../entities/wos-coupon.entity';
@Module({
imports: [TypeOrmModule.forFeature([WosUser, CouponLog, WosCoupon])],
controllers: [WosController],
providers: [WosService],
exports: [WosService],
})
export class WosModule {}

304
back/src/wos/wos.service.ts Normal file
View File

@ -0,0 +1,304 @@
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import * as crypto from 'crypto';
import { WosUser } from '../entities/wos-user.entity';
import { CouponLog } from '../entities/coupon-log.entity';
import { WosCoupon } from '../entities/wos-coupon.entity';
const SECRET = 'tB87#kPtkxqOS2';
const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api';
const OCR_SERVER = 'http://127.0.0.1:5001';
const COMMON_HEADERS = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Origin': 'https://wos-giftcode.centurygame.com',
'Referer': 'https://wos-giftcode.centurygame.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
};
const SUCCESS_MSG = '성공';
const ALREADY_MSG = '이미 수령';
@Injectable()
export class WosService {
private readonly logger = new Logger(WosService.name);
constructor(
@InjectRepository(WosUser)
private wosUserRepo: Repository<WosUser>,
@InjectRepository(CouponLog)
private couponLogRepo: Repository<CouponLog>,
@InjectRepository(WosCoupon)
private wosCouponRepo: Repository<WosCoupon>,
) {}
private now(): number {
return Date.now();
}
private makePlayerSign(fid: string, timestamp: number): string {
const raw = `fid=${fid}&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private makeCaptchaSign(fid: string, timestamp: number): string {
const raw = `fid=${fid}&init=0&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private makeGiftSign(fid: string, timestamp: number, cdk: string, captchaCode: string): string {
const raw = `captcha_code=${captchaCode}&cdk=${cdk}&fid=${fid}&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private async solveCaptcha(fid: string): Promise<string> {
const ts = this.now();
const sign = this.makeCaptchaSign(fid, ts);
const resp = await axios.post(
`${WOS_API_BASE}/captcha`,
new URLSearchParams({ fid, time: String(ts), sign, init: '0' }),
{ headers: { ...COMMON_HEADERS } },
);
if (resp.data.code !== 0) throw new Error(`Captcha 요청 실패: ${resp.data.msg}`);
const imgB64: string = resp.data.data.img;
const imgData = imgB64.includes(',') ? imgB64.split(',')[1] : imgB64;
const imgBuffer = Buffer.from(imgData, 'base64');
const ocrResp = await axios.post(OCR_SERVER, imgBuffer, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 5000,
});
return String(ocrResp.data).trim().toLowerCase();
}
async getPlayer(fid: string) {
const timestamp = this.now();
const sign = this.makePlayerSign(fid, timestamp);
const response = await axios.post(
`${WOS_API_BASE}/player`,
new URLSearchParams({ fid, time: String(timestamp), sign }),
{ headers: { ...COMMON_HEADERS } },
);
const data = response.data;
if (data.code !== 0) {
throw new HttpException(
data.msg || `오류가 발생했습니다. (${data.err_code})`,
HttpStatus.BAD_REQUEST,
);
}
const playerInfo = data.data;
const existing = await this.wosUserRepo.findOne({ where: { fid } });
if (existing) {
existing.nickname = playerInfo.nickname;
existing.avatar_url = playerInfo.avatar_image;
await this.wosUserRepo.save(existing);
} else {
await this.wosUserRepo.save({ fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image });
}
return playerInfo;
}
private async redeemOne(
fid: string,
code: string,
maxRetries = 5,
): Promise<{ success: boolean; message: string; err_code?: number }> {
const ts1 = this.now();
const playerSign = this.makePlayerSign(fid, ts1);
await axios.post(
`${WOS_API_BASE}/player`,
new URLSearchParams({ fid, time: String(ts1), sign: playerSign }),
{ headers: { ...COMMON_HEADERS } },
);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
let captchaCode: string;
try {
captchaCode = await this.solveCaptcha(fid);
} catch (err: any) {
return { success: false, message: `캡차 오류: ${err.message}` };
}
const ts2 = this.now();
const giftSign = this.makeGiftSign(fid, ts2, code, captchaCode);
const response = await axios.post(
`${WOS_API_BASE}/gift_code`,
new URLSearchParams({ fid, time: String(ts2), sign: giftSign, cdk: code, captcha_code: captchaCode }),
{ headers: { ...COMMON_HEADERS } },
);
const data = response.data;
this.logger.log(`[${fid}] ${code} attempt=${attempt} captcha=${captchaCode} → code=${data.code} err=${data.err_code} msg=${data.msg}`);
if (data.code === 0) return { success: true, message: SUCCESS_MSG };
if (data.err_code === 40103) {
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 500));
continue;
}
return { success: false, message: `캡차 인식 실패 (${maxRetries}회 시도)`, err_code: data.err_code };
}
return { success: false, message: data.msg || `실패 (${data.err_code})`, err_code: data.err_code };
}
return { success: false, message: '알 수 없는 오류' };
}
async redeemAll(fid: string) {
const activeCoupons = await this.wosCouponRepo.find({
where: { is_expired: false },
});
if (activeCoupons.length === 0) return [];
const existingLogs = await this.couponLogRepo.find({
where: { fid, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) },
});
const alreadySucceeded = new Set(existingLogs.map((l) => l.coupon_code));
const results: { name: string; code: string; status: string; message: string }[] = [];
for (const coupon of activeCoupons) {
if (alreadySucceeded.has(coupon.code)) {
results.push({ name: coupon.name, code: coupon.code, status: 'skipped', message: '이미 지급 완료' });
continue;
}
let result: { success: boolean; message: string; err_code?: number };
try {
result = await this.redeemOne(fid, coupon.code);
} catch (err: any) {
result = { success: false, message: err.message || '오류 발생' };
}
if (result.err_code === 40008 || result.err_code === 40011) {
result = { success: true, message: ALREADY_MSG, err_code: result.err_code };
}
if (result.err_code === 40007) {
await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false });
result.message = '만료된 쿠폰';
}
const status = result.success ? 'success' : 'error';
await this.couponLogRepo.save({ fid, coupon_code: coupon.code, result_msg: result.message });
results.push({ name: coupon.name, code: coupon.code, status, message: result.message });
await new Promise((r) => setTimeout(r, 1500));
}
return results;
}
@Cron('0 0 9 * * *')
async scheduledRedeemAll() {
this.logger.log('자동 쿠폰 지급 시작...');
const users = await this.wosUserRepo.find();
for (const user of users) {
try {
await this.redeemAll(user.fid);
this.logger.log(`[${user.fid}] ${user.nickname} 지급 완료`);
} catch (err: any) {
this.logger.error(`[${user.fid}] 지급 실패: ${err.message}`);
}
await new Promise((r) => setTimeout(r, 2000));
}
this.logger.log('자동 쿠폰 지급 완료');
}
async getHistory(fid: string) {
return this.couponLogRepo.find({
where: { fid },
order: { executed_at: 'DESC' },
take: 100,
});
}
async listUsers() {
return this.wosUserRepo.find({ order: { created_at: 'DESC' } });
}
async searchUsersByNickname(nickname: string) {
return this.wosUserRepo
.createQueryBuilder('u')
.where('u.nickname LIKE :q', { q: `%${nickname}%` })
.orderBy('u.nickname', 'ASC')
.limit(20)
.getMany();
}
async listCoupons() {
return this.wosCouponRepo.find({ order: { created_at: 'DESC' } });
}
async addCoupon(name: string, code: string) {
const exists = await this.wosCouponRepo.findOne({ where: { code } });
if (exists) {
exists.name = name;
exists.is_active = true;
exists.is_expired = false;
await this.wosCouponRepo.save(exists);
} else {
await this.wosCouponRepo.save({ name, code, is_active: true, is_expired: false });
}
this.redeemCouponForAllUsers(code).catch((err) =>
this.logger.error(`신규 쿠폰 전체 지급 실패: ${err.message}`),
);
return this.wosCouponRepo.findOne({ where: { code } });
}
private async redeemCouponForAllUsers(code: string) {
const users = await this.wosUserRepo.find();
for (const user of users) {
const alreadyDone = await this.couponLogRepo.findOne({
where: { fid: user.fid, coupon_code: code, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) },
});
if (alreadyDone) continue;
try {
const result = await this.redeemOne(user.fid, code);
const finalResult =
result.err_code === 40008 || result.err_code === 40011
? { success: true, message: ALREADY_MSG }
: result;
if (result.err_code === 40007) {
const coupon = await this.wosCouponRepo.findOne({ where: { code } });
if (coupon) await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false });
await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: '만료된 쿠폰' });
break;
}
await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: finalResult.message });
this.logger.log(`[${user.fid}] ${code}${finalResult.message}`);
} catch (err: any) {
this.logger.error(`[${user.fid}] ${code} 지급 실패: ${err.message}`);
}
await new Promise((r) => setTimeout(r, 1500));
}
}
async toggleCoupon(id: number, is_active: boolean) {
await this.wosCouponRepo.update(id, { is_active });
return this.wosCouponRepo.findOne({ where: { id } });
}
async deleteCoupon(id: number) {
await this.wosCouponRepo.delete(id);
return { success: true };
}
}

21
back/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

6
front/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
build/
.cache/
*.log
.env

12
front/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WOS 쿠폰 자동 입력</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1584
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
front/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "auto-coupon-front",
"version": "1.0.0",
"description": "WOS 쿠폰 자동 입력 Vue3 프론트엔드",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"pinia": "^2.1.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

32
front/src/App.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<div id="app">
<AdminView v-if="isAdmin" />
<HomeView v-else />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import HomeView from './views/HomeView.vue';
import AdminView from './views/AdminView.vue';
const isAdmin = computed(() => window.location.pathname === '/admin');
</script>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100%;
}
body {
background: #f0f4f8;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #1e293b;
min-height: 100vh;
}
#app {
width: 100%;
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="redeem-results" v-if="store.results.length > 0">
<div class="results-header">
<span class="label">이번 지급 결과</span>
<span class="summary">
성공 {{ successCount }} / 전체 {{ store.results.length }}
</span>
</div>
<div class="result-list">
<div v-for="(r, i) in store.results" :key="i" class="result-row">
<div class="result-info">
<span class="result-name">{{ r.name }}</span>
<span class="result-code">{{ r.code }}</span>
</div>
<span class="badge" :class="r.status">{{ r.message }}</span>
</div>
</div>
</div>
<div class="empty-result" v-else-if="store.status === 'idle' && store.player">
<p>쿠폰이 없거나 모두 지급됐습니다.</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useWosStore } from '../stores/wos.store';
const store = useWosStore();
const successCount = computed(() => store.results.filter((r) => r.status === 'success').length);
</script>
<style scoped>
.redeem-results { display: flex; flex-direction: column; gap: 10px; }
.results-header { display: flex; justify-content: space-between; align-items: center; }
.label { color: #94a3b8; font-size: 0.85rem; }
.summary { color: #60a5fa; font-size: 0.85rem; font-weight: 600; }
.result-list { 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;
gap: 8px;
}
.result-info { display: flex; flex-direction: column; gap: 2px; }
.result-name { color: #cbd5e1; font-size: 0.9rem; }
.result-code { color: #475569; font-family: monospace; font-size: 0.78rem; }
.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; }
.badge.pending { background: #1e3a5f; color: #93c5fd; }
.empty-result p { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
</style>

View File

@ -0,0 +1,110 @@
<template>
<div class="coupon-manager">
<div class="manager-header">
<h3>🎟 쿠폰 관리</h3>
<span class="active-count">{{ activeCoupons }}</span>
</div>
<div class="add-form">
<input v-model="newCode" type="text" placeholder="쿠폰 코드" class="input code-input" />
<button class="btn btn-add" @click="add" :disabled="!newCode">추가</button>
</div>
<div class="coupon-list" v-if="store.coupons.length > 0">
<div
v-for="c in store.coupons"
:key="c.id"
class="coupon-item"
:class="{ expired: c.is_expired }"
>
<div class="coupon-info">
<span class="coupon-code">{{ c.code }}</span>
</div>
<div class="coupon-actions">
<span v-if="c.is_expired" class="badge badge-expired">만료</span>
<span v-else class="badge badge-active">활성</span>
</div>
</div>
</div>
<p class="empty" v-else>등록된 쿠폰이 없습니다.</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useWosStore } from '../stores/wos.store';
const store = useWosStore();
const newCode = ref('');
const activeCoupons = computed(() => store.coupons.filter((c) => !c.is_expired).length);
async function add() {
if (!newCode.value.trim()) return;
const code = newCode.value.trim();
await store.addCoupon(code, code);
newCode.value = '';
}
</script>
<style scoped>
.coupon-manager { display: flex; flex-direction: column; gap: 14px; }
.manager-header { display: flex; align-items: center; justify-content: space-between; }
.manager-header h3 { margin: 0; color: #f1f5f9; font-size: 1rem; }
.active-count { background: #1e3a5f; color: #60a5fa; padding: 3px 10px; border-radius: 20px; font-size: 0.8rem; font-weight: 600; }
.add-form { display: flex; gap: 8px; flex-wrap: wrap; }
.input {
padding: 8px 12px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.9rem;
flex: 1;
min-width: 120px;
}
.input:focus { outline: none; border-color: #60a5fa; }
.code-input { font-family: monospace; }
.btn-add {
padding: 8px 16px;
background: #22c55e;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
}
.btn-add:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-add:hover:not(:disabled) { background: #16a34a; }
.coupon-list { display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; }
.coupon-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #0f172a;
border-radius: 8px;
border-left: 3px solid #22c55e;
gap: 8px;
}
.coupon-item.expired { border-left-color: #7c3aed; opacity: 0.5; }
.coupon-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.coupon-code { color: #60a5fa; font-family: monospace; font-size: 0.9rem; }
.coupon-actions { display: flex; gap: 6px; align-items: center; }
.btn-toggle {
padding: 3px 10px;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
}
.btn-toggle.on { background: #166534; color: #4ade80; }
.btn-toggle.off { background: #374151; color: #9ca3af; }
.badge { padding: 3px 10px; border-radius: 20px; font-size: 0.78rem; font-weight: 600; }
.badge-active { background: #166534; color: #4ade80; }
.badge-expired { background: #3b0764; color: #c084fc; }
.empty { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
</style>

View File

@ -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>

View File

@ -0,0 +1,82 @@
<template>
<div class="result-table" v-if="results.length > 0">
<h3 class="title">입력 결과</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>쿠폰 코드</th>
<th>결과</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in results" :key="i" :class="r.status">
<td>{{ i + 1 }}</td>
<td class="code">{{ r.code }}</td>
<td>
<span class="badge" :class="r.status">{{ r.message }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
defineProps<{
results: { code: string; status: string; message: string }[];
}>();
</script>
<style scoped>
.result-table {
margin-top: 20px;
}
.title {
color: #f1f5f9;
margin-bottom: 10px;
font-size: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
background: #1e293b;
color: #94a3b8;
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #334155;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #1e293b;
color: #cbd5e1;
}
tr:hover td {
background: #1e293b;
}
.code {
font-family: monospace;
color: #60a5fa;
}
.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>

View File

@ -0,0 +1,56 @@
<template>
<div class="user-card" v-if="player">
<img :src="player.avatar_image" alt="아바타" class="avatar" @error="onImgError" />
<div class="info">
<h3>{{ player.nickname }}</h3>
<p>용광로 레벨: <strong>{{ player.stove_lv }}</strong></p>
<p>ID: <span class="fid">{{ fid }}</span></p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
player: { nickname: string; avatar_image: string; stove_lv: number } | null;
fid: string;
}>();
function onImgError(e: Event) {
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/64';
}
</script>
<style scoped>
.user-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #3b82f6;
}
.info h3 {
margin: 0 0 4px;
color: #1e293b;
font-size: 1.2rem;
}
.info p {
margin: 2px 0;
color: #64748b;
font-size: 0.9rem;
}
.fid {
color: #3b82f6;
font-family: monospace;
}
</style>

7
front/src/main.ts Normal file
View File

@ -0,0 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.mount('#app');

View File

@ -0,0 +1,120 @@
import { defineStore } from 'pinia';
import axios from 'axios';
interface PlayerInfo {
nickname: string;
avatar_image: string;
stove_lv: number;
}
interface RedeemResult {
name: string;
code: string;
status: 'success' | 'error' | 'pending';
message: string;
}
interface HistoryItem {
id: number;
fid: string;
coupon_code: string;
result_msg: string;
executed_at: string;
}
export interface Coupon {
id: number;
name: string;
code: string;
is_active: boolean;
is_expired: boolean;
created_at: string;
}
export interface SavedUser {
fid: string;
nickname: string;
avatar_url: string;
created_at: string;
}
export const useWosStore = defineStore('wos', {
state: () => ({
fid: '',
player: null as PlayerInfo | null,
results: [] as RedeemResult[],
history: [] as HistoryItem[],
coupons: [] as Coupon[],
savedUsers: [] as SavedUser[],
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
errorMsg: '',
}),
actions: {
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;
localStorage.setItem('wos_fid', fid);
await Promise.all([this.loadHistory(), this.redeemAll()]);
this.status = 'idle';
} catch (err: any) {
this.status = 'error';
this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.';
}
},
async redeemAll() {
if (!this.fid) return;
try {
const { data } = await axios.post('/api/redeem-all', { fid: this.fid });
this.results = data;
await this.loadHistory();
} catch (err: any) {
this.errorMsg = err.response?.data?.message || '쿠폰 지급 중 오류 발생';
}
},
async loadHistory() {
if (!this.fid) return;
try {
const { data } = await axios.get(`/api/history/${this.fid}`);
this.history = data;
} catch {}
},
async loadSavedUsers() {
try {
const { data } = await axios.get('/api/users');
this.savedUsers = data;
} catch {}
},
async loadCoupons() {
try {
const { data } = await axios.get('/api/coupons');
this.coupons = data;
} catch {}
},
async addCoupon(name: string, code: string) {
await axios.post('/api/coupons', { name, code });
await this.loadCoupons();
},
async toggleCoupon(id: number, is_active: boolean) {
await axios.patch(`/api/coupons/${id}`, { is_active });
await this.loadCoupons();
},
async deleteCoupon(id: number) {
await axios.delete(`/api/coupons/${id}`);
await this.loadCoupons();
},
},
});

View File

@ -0,0 +1,145 @@
<template>
<div class="admin">
<header>
<h1>🔧 쿠폰 관리</h1>
<a href="/" class="back-link"> 메인으로</a>
</header>
<div class="card">
<h3 class="section-title">쿠폰 추가</h3>
<div class="form-row">
<input v-model="newCode" type="text" placeholder="쿠폰 코드" class="input" @keydown.enter="addCoupon" />
<input v-model="newName" type="text" placeholder="쿠폰 이름 (예: 신년 이벤트)" class="input" @keydown.enter="addCoupon" />
<button class="btn btn-primary" @click="addCoupon" :disabled="!newCode.trim()">추가</button>
</div>
<p class="error-msg" v-if="errorMsg">{{ errorMsg }}</p>
</div>
<div class="card">
<div class="list-header">
<h3 class="section-title">쿠폰 목록 ({{ store.coupons.length }})</h3>
<span class="active-count">활성: {{ activeCoupons }}</span>
</div>
<div v-if="store.coupons.length === 0" class="empty">등록된 쿠폰이 없습니다.</div>
<div v-for="c in store.coupons" :key="c.id" class="coupon-row">
<div class="coupon-info">
<span class="coupon-name">{{ c.name || '(이름 없음)' }}</span>
<span class="coupon-code">{{ c.code }}</span>
</div>
<div class="coupon-actions">
<button class="btn btn-danger" @click="confirmDelete(c.id, c.name || c.code)">삭제</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useWosStore } from '../stores/wos.store';
const store = useWosStore();
const newCode = ref('');
const newName = ref('');
const errorMsg = ref('');
const activeCoupons = computed(() => store.coupons.filter((c) => !c.is_expired).length);
onMounted(() => store.loadCoupons());
async function addCoupon() {
if (!newCode.value.trim()) return;
errorMsg.value = '';
try {
await store.addCoupon(newCode.value.trim(), newName.value.trim());
newCode.value = '';
newName.value = '';
} catch (err: any) {
errorMsg.value = err.response?.data?.message || '추가 실패';
}
}
function confirmDelete(id: number, label: string) {
if (confirm(`"${label}" 쿠폰을 삭제하시겠습니까?`)) {
store.deleteCoupon(id);
}
}
</script>
<style scoped>
.admin {
max-width: 720px;
margin: 0 auto;
padding: 32px 16px 60px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
header h1 { margin: 0; color: #f1f5f9; font-size: 1.6rem; }
.back-link { color: #60a5fa; text-decoration: none; font-size: 0.9rem; }
.back-link:hover { text-decoration: underline; }
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 14px;
color: #94a3b8;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.input {
flex: 1;
min-width: 140px;
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.95rem;
}
.input:focus { outline: none; border-color: #60a5fa; }
.btn {
padding: 10px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #2563eb; }
.btn-danger { background: #7f1d1d; color: #fca5a5; }
.btn-danger:hover { background: #991b1b; }
.error-msg { color: #f87171; font-size: 0.9rem; margin: 8px 0 0; }
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.active-count { color: #4ade80; font-size: 0.85rem; }
.empty { color: #475569; text-align: center; padding: 20px 0; }
.coupon-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #0f172a;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid #1e293b;
}
.coupon-info { display: flex; flex-direction: column; gap: 3px; }
.coupon-name { color: #f1f5f9; font-size: 0.95rem; font-weight: 500; }
.coupon-code { color: #60a5fa; font-family: monospace; font-size: 0.85rem; }
.coupon-actions { display: flex; align-items: center; gap: 12px; }
</style>

View File

@ -0,0 +1,204 @@
<template>
<div class="layout">
<header>
<h1> WOS 쿠폰 자동 입력</h1>
<p class="subtitle">FID를 입력하면 등록된 모든 쿠폰을 자동으로 지급합니다</p>
</header>
<div class="columns">
<div class="col-left">
<div class="card">
<h3 class="section-title">등록된 유저 ({{ filteredUsers.length }})</h3>
<input
v-model="userSearchQuery"
type="text"
class="input user-search"
placeholder="닉네임 / FID 검색"
/>
<div class="user-list" v-if="filteredUsers.length > 0">
<button
v-for="u in filteredUsers"
:key="u.fid"
class="user-item"
:class="{ active: store.fid === u.fid }"
@click="selectUser(u.fid)"
>
<img :src="u.avatar_url" class="user-avatar" @error="onImgErr" />
<span class="user-name">{{ u.nickname }}</span>
</button>
</div>
<p class="empty" v-else>결과가 없습니다.</p>
</div>
</div>
<div class="col-right">
<div class="card">
<div class="search-row">
<input
v-model="fidInput"
type="text"
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'"> 처리 중...</span>
<span v-else>🎁 쿠폰 받기</span>
</button>
</div>
<p class="error-msg" v-if="store.status === 'error'">{{ store.errorMsg }}</p>
</div>
<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>
<div class="card admin-card">
<CouponManager />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useWosStore } from '../stores/wos.store';
import UserCard from '../components/UserCard.vue';
import CouponInput from '../components/CouponInput.vue';
import CouponManager from '../components/CouponManager.vue';
const store = useWosStore();
const fidInput = ref('');
const userSearchQuery = ref('');
const filteredUsers = computed(() => {
const q = userSearchQuery.value.trim().toLowerCase();
if (!q) return store.savedUsers;
return store.savedUsers.filter(
(u) => u.nickname?.toLowerCase().includes(q) || u.fid.includes(q)
);
});
onMounted(async () => {
store.loadCoupons();
store.loadSavedUsers();
const savedFid = localStorage.getItem('wos_fid');
if (savedFid) {
fidInput.value = savedFid;
await store.searchPlayer(savedFid);
store.loadSavedUsers();
}
});
async function searchPlayer() {
if (!fidInput.value.trim()) return;
const fid = fidInput.value.trim();
localStorage.setItem('wos_fid', fid);
await store.searchPlayer(fid);
store.loadSavedUsers();
}
async function selectUser(fid: string) {
fidInput.value = fid;
localStorage.setItem('wos_fid', fid);
await store.searchPlayer(fid);
store.loadSavedUsers();
}
function onImgErr(e: Event) {
(e.target as HTMLImageElement).style.display = 'none';
}
</script>
<style scoped>
.layout {
max-width: 1100px;
margin: 0 auto;
padding: 32px 16px 60px;
}
header {
text-align: center;
margin-bottom: 28px;
}
header h1 { font-size: 1.9rem; color: #1e293b; margin: 0 0 8px; }
.subtitle { color: #64748b; margin: 0; font-size: 0.9rem; }
.columns {
display: flex;
gap: 16px;
align-items: flex-start;
}
.col-left {
width: 220px;
flex-shrink: 0;
}
.col-right {
flex: 1;
min-width: 0;
}
.card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.admin-card { border-style: dashed; border-color: #cbd5e1; }
.section-title { margin: 0 0 10px; color: #64748b; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
.user-search { width: 100%; box-sizing: border-box; margin-bottom: 10px; font-size: 0.85rem; padding: 7px 10px; }
.user-list { display: flex; flex-direction: column; gap: 6px; max-height: calc(100vh - 320px); overflow-y: auto; }
.user-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #334155;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
text-align: left;
width: 100%;
}
.user-item:hover { border-color: #3b82f6; color: #1e293b; background: #f0f7ff; }
.user-item.active { border-color: #3b82f6; background: #eff6ff; color: #2563eb; }
.user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.user-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-row { display: flex; gap: 10px; }
.input {
flex: 1;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #1e293b;
font-size: 0.95rem;
}
.input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); }
.input:disabled { opacity: 0.5; }
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 700;
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: #dc2626; font-size: 0.9rem; margin: 8px 0 0; }
.empty { color: #94a3b8; font-size: 0.9rem; margin: 0; text-align: center; padding: 12px 0; }
</style>

17
front/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

15
front/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});